Compare commits
181
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39df53e53a | ||
|
|
c2f3feec7e | ||
|
|
3e042eed2d | ||
|
|
183fd316ef | ||
|
|
58e1c23592 | ||
|
|
3e7e53d2fc | ||
|
|
b9db438034 | ||
|
|
0273990196 | ||
|
|
782d2bea72 | ||
|
|
f08883cb69 | ||
|
|
3150aeecaa | ||
|
|
c9c9a0dd6f | ||
|
|
2283b72b2f | ||
|
|
940a3b4534 | ||
|
|
35a9c7ac80 | ||
|
|
2e8240347f | ||
|
|
d69ff4a6f9 | ||
|
|
4ea4934b3c | ||
|
|
add9252b43 | ||
|
|
45d5745b54 | ||
|
|
c92054fb5c | ||
|
|
2e8d94d0bb | ||
|
|
bfbca9dce4 | ||
|
|
2c69eb71d7 | ||
|
|
c50e0e5d0e | ||
|
|
f2dcf8970e | ||
|
|
e882612249 | ||
|
|
9c9a007419 | ||
|
|
56b70481e8 | ||
|
|
55351bc997 | ||
|
|
33e4d14b81 | ||
|
|
7901c7919d | ||
|
|
3b8402374a | ||
|
|
6635defb2e | ||
|
|
7fb073d462 | ||
|
|
2e59f8786b | ||
|
|
ba6069962a | ||
|
|
375de59e28 | ||
|
|
3b84a86b8f | ||
|
|
a5dac2349d | ||
|
|
da6de52198 | ||
|
|
87016e5581 | ||
|
|
e0e45ac048 | ||
|
|
1b3ba04b5b | ||
|
|
5c5180336b | ||
|
|
73bf863e40 | ||
|
|
06d0e354fe | ||
|
|
a01fba6602 | ||
|
|
54ebf2365c | ||
|
|
24eaa7f9df | ||
|
|
8e3eb3a9d7 | ||
|
|
01ce8b8ec3 | ||
|
|
5dd6809ef0 | ||
|
|
7b8bc52650 | ||
|
|
1758c3b6d2 | ||
|
|
6669633902 | ||
|
|
a65d282c0b | ||
|
|
524b43cc40 | ||
|
|
4e0b27c4d7 | ||
|
|
c6d7acfd99 | ||
|
|
bf941c71ec | ||
|
|
dcec510451 | ||
|
|
cb468b0f3c | ||
|
|
6713510ce9 | ||
|
|
bf5b32de16 | ||
|
|
2a1780b43d | ||
|
|
be05ec99b5 | ||
|
|
3a42bc034d | ||
|
|
27e7664ef0 | ||
|
|
ccbf1d3269 | ||
|
|
0ca14739f9 | ||
|
|
d9192ef355 | ||
|
|
4c9bfd576c | ||
|
|
20898cade7 | ||
|
|
2c12639499 | ||
|
|
7d2b2067ef | ||
|
|
0b52e6ee86 | ||
|
|
cafac69008 | ||
|
|
871875b8e5 | ||
|
|
e5ae3e48d6 | ||
|
|
76de3dd75e | ||
|
|
687427381d | ||
|
|
e418d87802 | ||
|
|
a1f3690085 | ||
|
|
0a7bed10bf | ||
|
|
32996ce9aa | ||
|
|
b42265b41e | ||
|
|
0fc7654f6c | ||
|
|
effad97472 | ||
|
|
53d98bfffe | ||
|
|
62aa6079be | ||
|
|
7d48f65ff9 | ||
|
|
3acadf6ad7 | ||
|
|
5492f99f06 | ||
|
|
71221e055b | ||
|
|
86330e8f65 | ||
|
|
f3b507b8d9 | ||
|
|
c01c7e862d | ||
|
|
99cd765997 | ||
|
|
5266506b56 | ||
|
|
682a5030d3 | ||
|
|
fc3d847d85 | ||
|
|
82756030c6 | ||
|
|
0352451ead | ||
|
|
593cf492c3 | ||
|
|
0b8ad8b0f3 | ||
|
|
111d927ece | ||
|
|
2dae023ee3 | ||
|
|
b12122f78a | ||
|
|
0c7b3162c8 | ||
|
|
d4d050297b | ||
|
|
8d768bf9d5 | ||
|
|
47ac8bf6c0 | ||
|
|
01572736c0 | ||
|
|
b6e5f01748 | ||
|
|
90da0ac990 | ||
|
|
bc26d1506b | ||
|
|
769e3ca559 | ||
|
|
b5d789f02a | ||
|
|
c8dce25e0d | ||
|
|
b322e2d24a | ||
|
|
414ec69c0a | ||
|
|
f5ca656c04 | ||
|
|
e158232aba | ||
|
|
632871ac24 | ||
|
|
7af7b2c8c2 | ||
|
|
97132770e4 | ||
|
|
683ad3ef94 | ||
|
|
604383f51c | ||
|
|
bdeb2f00b3 | ||
|
|
8688875fae | ||
|
|
a42271c7de | ||
|
|
cf4685eaf1 | ||
|
|
5023fb6774 | ||
|
|
04eb70cbe4 | ||
|
|
4dc010f54d | ||
|
|
f7e36bc44e | ||
|
|
a9ac1c06a7 | ||
|
|
9bf6e7492f | ||
|
|
14070cef43 | ||
|
|
6b1abbb50e | ||
|
|
46c641ebc5 | ||
|
|
2d036f5126 | ||
|
|
2c69e357b8 | ||
|
|
ba81f22d40 | ||
|
|
070add4e7b | ||
|
|
847b3225b2 | ||
|
|
3ae5c7efac | ||
|
|
4966c27548 | ||
|
|
06c12d0383 | ||
|
|
69f3968d4a | ||
|
|
d5ae66466a | ||
|
|
2dc8be3127 | ||
|
|
040e80a36d | ||
|
|
bd6b79fb41 | ||
|
|
581a31161c | ||
|
|
5f8f93ba6c | ||
|
|
40b3e9c5dd | ||
|
|
2ffc219f47 | ||
|
|
02394d3278 | ||
|
|
ff1ccca3cd | ||
|
|
fddbaec82e | ||
|
|
8e073438a3 | ||
|
|
6972e9ec87 | ||
|
|
f29a22886a | ||
|
|
287afa1537 | ||
|
|
b0ac6dfdb9 | ||
|
|
7f06606300 | ||
|
|
985af3a294 | ||
|
|
440cfdcc31 | ||
|
|
0ca92c3723 | ||
|
|
f434f30286 | ||
|
|
f4b8935189 | ||
|
|
014df322e7 | ||
|
|
39f9cc16e4 | ||
|
|
b4b3032456 | ||
|
|
edef1ea708 | ||
|
|
7523670949 | ||
|
|
d81f328859 | ||
|
|
1a39d601de | ||
|
|
68d3dd85a9 |
@@ -104,3 +104,41 @@ DEEPSEEK_MODEL=deepseek-chat
|
||||
# OBSIGATE_GITEA_URL=https://git.example.net
|
||||
# OBSIGATE_GITEA_TOKEN=
|
||||
# OBSIGATE_GITHUB_TOKEN=
|
||||
|
||||
# ── Fusion / dev-prod — URL publiques de l'écosystème (#223) ──
|
||||
# Aucun nom public n'est figé dans le code : ces variables portent les
|
||||
# URL des autres applications. Valeurs par défaut (si absentes) = noms
|
||||
# OFFICIELS de la production. Depuis BUG-120, les URL sœurs se dérivent
|
||||
# de l'hôte de l'agent (agent<SUFFIXE>.dracodev.net, lu via
|
||||
# AGENT_EVENEMENTS_URL / DRACO_AGENT_URL) : en dev, rien d'autre à
|
||||
# déclarer pour obtenir les noms en -dev. Une variable explicite
|
||||
# reste prioritaire sur la dérivation, par exemple :
|
||||
# DRACO_TIKTAK_URL=https://tiktak-dev.dracodev.net
|
||||
# DRACO_AGENT_URL=https://agent.dracodev.net
|
||||
# DRACO_OBSIGATE_URL=https://og.dracodev.net
|
||||
# DRACO_FLOWDECK_URL=https://flowdeck.dracodev.net
|
||||
# DRACO_TIKTAK_URL=https://tiktak.dracodev.net
|
||||
# DRACO_KANBANTE_URL=https://kanbante.dracodev.net
|
||||
# DRACO_GITEA_URL=https://git.dracodev.net
|
||||
|
||||
# ── Fusion — émission d'événements vers le service d'agents (#220) ──
|
||||
# URL complète de réception ; à défaut, dérivée de DRACO_AGENT_URL.
|
||||
# AGENT_EVENEMENTS_URL=https://agent.dracodev.net/api/v1/evenements
|
||||
# AGENT_EVENEMENTS_CLE=<clé propre à ObsiGate, jamais commitée>
|
||||
# AGENT_EVENEMENTS_DOMAINES={"NomDuVault": "famille"}
|
||||
|
||||
# ── Fusion — émettrice SSO draco_session (#216/#217) ──
|
||||
# DRACO_SESSION_SECRET=<secret partagé, jamais commité>
|
||||
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
|
||||
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
|
||||
# DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée>
|
||||
|
||||
# ── Coffre Secrets ObsiGate (Phases 1–2, voir docs/COFFRE-sauvegarde-restauration.md) ──
|
||||
# Activation (défaut : désactivé — toutes les routes répondent 503 sinon) :
|
||||
# OBSIGATE_SECRETS_ENABLED=true
|
||||
# Clé maître 256 bits, base64 de 32 octets, rendue une seule fois par
|
||||
# POST /api/coffres/admin/generer-kek ; à sauvegarder HORS dépôt, HORS note :
|
||||
# OBSIGATE_KEK=<kek-base64-32-octets>
|
||||
# … ou fichier 0600, hors dépôt et hors arborescence des vaults :
|
||||
# OBSIGATE_KEK_FILE=/chemin/hors/depot/kek.txt
|
||||
# Aucune autre variable ne doit contenir une valeur de secret du coffre.
|
||||
|
||||
@@ -180,7 +180,13 @@ jobs:
|
||||
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
|
||||
# l'extraction de texte et la lecture d'outlines — donc atteignables
|
||||
# via backend/pdf_reader.py) sont corrigés par le plancher
|
||||
# pypdf>=6.16.1 (BUG-093).
|
||||
# pypdf>=6.16.1 (BUG-093), relevé à pypdf>=6.21.2 par #224 (11 CVE
|
||||
# sur la 6.19.0 de la toolcache, corrigées au plus tard en 6.21.2).
|
||||
# #224 ajoute les planchers brotli>=1.2.1 (PYSEC-2026-4322,
|
||||
# GHSA-9v8h-qqgm-4m35), filelock>=4.2.1 (CVE-2026-112940,
|
||||
# CVE-2026-112913, CVE-2026-112907) et opentelemetry-api>=1.45.1
|
||||
# (CVE-2026-114603) : paquets transitifs ou préinstallés dans la
|
||||
# toolcache, audités mais jamais alignés sans plancher explicite.
|
||||
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
|
||||
# corrigés par le plancher urllib3>=2.8.0.
|
||||
# CVE-2026-104874 (multidict 6.7.x) corrigé par le plancher
|
||||
|
||||
+307
-1
@@ -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.71.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.81.4**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,312 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.81.4] — 2026-10-28
|
||||
|
||||
### Modifié
|
||||
|
||||
- **Barre (#70)** : bouton « ••• » retiré (menu par défaut supprimé), « Console Spark » ajoutée au menu de la marque, fermeture/focus corrigés.
|
||||
- **Alertes unifiées (#69)** : panneau actives puis historique, par application, avec pastille ; agrégation à la demande, pas une fédération temps réel ; CSP connect-src sœurs.
|
||||
- **Rechercher partout (#68)** : page `/recherche` unifiée par application, liens profonds dans l'environnement courant (dérivation `-dev` respectée), CORS bornée à `/api/search` ; la palette de la barre ouvre désormais `/recherche`.
|
||||
|
||||
## [2.81.3] — 2026-10-28
|
||||
|
||||
### Modifié
|
||||
|
||||
- **Barre d'apps** — vendange de `dracodev-commons` v0.1.6 : dérivation `-dev` par l'hôte courant, menu « ••• » par défaut, menu compte fonctionnel, spark sans dérivation inventée.
|
||||
|
||||
---
|
||||
|
||||
## [2.81.2] — 2026-10-28
|
||||
|
||||
### Modifié
|
||||
|
||||
- **Panneau Spark / console (PR #35)** — style console du panneau, statut « Connecté » intégré au sous-titre de l'en-tête (barre séparée supprimée), texte sous la zone de saisie supprimé, composer identique à la console (pilule, pièces jointes 4 max, textarea auto-extensible, micro dictée, envoi rond bleu), resynchronisation du domaine à chaque ouverture / changement de stockage / retour console, URL console avec `?domain=` ; la console honore `?domain=` valide (persisté avant chargement des discussions, PR agent-commun #7).
|
||||
|
||||
---
|
||||
|
||||
## [2.81.1] — 2026-10-28
|
||||
|
||||
---
|
||||
|
||||
## [2.81.0] — 2026-10-28
|
||||
|
||||
---
|
||||
|
||||
## [2.80.2] — 2026-10-27
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **CI lint (mypy)** — 3 erreurs dans le Coffre Secrets : annotation explicite `type[Any] | None` pour `AESGCM` dans `coffre_crypto.py` (le repli sans `cryptography` reste `None`, et `_require_aesgcm()` retourne désormais la classe pour un narrowing propre) ; annotation `dict[str, Any]` du coffre dans `create_coffre` (`coffre_store.py`), qui inférait ses valeurs en `str | None` et refusait le dict de `wrap_dek` ainsi que `coffre["id"]` en argument. Aucun `type: ignore` aveugle ajouté, aucun changement de comportement.
|
||||
|
||||
- **Bouton Nouveau secret inoperant (console NotFoundError)** — `valeurField` appelait `wrap.insertBefore(input, eyeBtn)` alors que le bouton oeil n'avait jamais ete rattache a `wrap` : l'oeil est desormais ajoute a `wrap` avant le premier `build`, la modale de creation (et celle de rotation) s'ouvre.
|
||||
- **CI security (pip-audit)** — plancher `msgpack>=1.2.3` : la version transitive 1.2.1 etait signalee pour CVE-2026-114613 (correctif 1.2.3).
|
||||
|
||||
---
|
||||
|
||||
## [2.80.1] — 2026-10-27
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Coffre Secrets — Phase 1** (contenu de la release 2.80.0, dont l'entrée changelog manquait) : chiffrement par enveloppe AES-256-GCM, API `/api/coffres`, nœud virtuel Secrets sous le Home de l'utilisateur.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Étiquette de version 2.80.0** — le tag v2.80.0 pointait sur un contenu dont le fichier `VERSION` valait encore 2.79.0 (un merge ne déclenche pas le bump automatique) ; `VERSION` et tous les fichiers dérivés passent à 2.80.1, pour que la version affichée corresponde au tag de la prochaine release.
|
||||
- **CI lint (ruff)** — 60 erreurs corrigées dans `backend/` : alias `datetime.UTC` (Python 3.11+), `TimeoutError` natif, tri d'imports et import inutilisé dans `coffre_store.py`, `.encode()` sans argument redondant, `fromisoformat` gérant « Z » ; aucune règle ruff masquée, aucun changement de comportement.
|
||||
|
||||
---
|
||||
|
||||
## [2.79.0] — 2026-10-26
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Panneau Spark : version du service d'agents visible en mode
|
||||
sidebar (#225)** — le pied du panneau Spark
|
||||
(`frontend/draco-panneau.js`, asset partagé des 4 applications)
|
||||
affiche la ligne discrète « Service d'agents vX », comme la
|
||||
console pleine page. La version est lue en direct dans la santé
|
||||
du service (`GET /healthz` de l'origine injectée par l'hôte), à
|
||||
l'ouverture du panneau, avec cache de session ; jamais une
|
||||
valeur codée en dur — service injoignable, rien d'affiché.
|
||||
Demande de Bruno (2026-10-26), carte fusion #64 ; même
|
||||
livraison : TikTak 0.61.3, Flowdeck 7.83.1, Kanbante 0.37.1.
|
||||
|
||||
---
|
||||
|
||||
## [2.78.2] — 2026-10-26
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Menu inter-apps : les URL sœurs suivaient l'environnement de
|
||||
l'agent (BUG-120, suite de #223)** — sur la dev
|
||||
(`og-dev.dracodev.net`), le `window.DRACO_URLS` servi donnait
|
||||
l'agent en `-dev` mais les applications sœurs (ObsiGate, Flowdeck,
|
||||
TikTak, Kanbante) en noms **prod** : seule l'URL de l'agent avait
|
||||
une dérivation de repli, et le `.env` dev ne déclare pas les quatre
|
||||
`DRACO_<APP>_URL`. Résolution d'une URL sœur dans
|
||||
`backend/draco_config.py`, inchangée dans son contrat : (1) la
|
||||
variable explicite prime ; (2) à défaut, **dérivation
|
||||
d'environnement** depuis l'hôte de l'agent tel qu'ObsiGate le
|
||||
connaît déjà (`agent_url()`, qui lit `AGENT_EVENEMENTS_URL`) — si
|
||||
cet hôte est de la forme `agent<SUFFIXE>.dracodev.net`, l'URL sœur
|
||||
devient `https://<slug><SUFFIXE>.dracodev.net` (`og`, `flowdeck`,
|
||||
`tiktak`, `kanbante`) ; hôte hors motif (autre domaine, LAN, IP,
|
||||
port) → **aucune dérivation**, jamais de lien deviné ; (3) défaut
|
||||
officiel. L'URL Gitea ne change pas. Aucune variable supplémentaire
|
||||
à déclarer en dev. Tests `tests/test_draco_config.py` étendus
|
||||
(dérivation `-dev`, priorité de l'explicite, hôtes hors motif,
|
||||
non-régression sans env). Redéploiement Docker requis.
|
||||
|
||||
---
|
||||
|
||||
## [2.78.1] — 2026-10-25
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Sécurité des dépendances — `pip-audit` rouge sur 2.78.0 (#224)** —
|
||||
le job `security` du run CI 2303 signalait 17 vulnérabilités dans
|
||||
4 paquets hérités de l'environnement du runner (versions
|
||||
préinstallées dans la toolcache, jamais alignées car « already
|
||||
satisfied » — même piège que BUG-093). Planchers explicites dans
|
||||
`backend/requirements.txt` : `pypdf>=6.21.2` (11 CVE sur la 6.19.0,
|
||||
dont CVE-2026-113786 et CVE-2026-115572/115585/115580),
|
||||
`brotli>=1.2.1` (PYSEC-2026-4322, GHSA-9v8h-qqgm-4m35),
|
||||
`filelock>=4.2.1` (CVE-2026-112940, CVE-2026-112913,
|
||||
CVE-2026-112907) et `opentelemetry-api>=1.45.1`
|
||||
(CVE-2026-114603). Garde-fou `TestDependencySecurityFloors`
|
||||
étendu ; aucun code applicatif touché, aucun nouvel ignore
|
||||
`pip-audit`. Redéploiement Docker requis.
|
||||
|
||||
---
|
||||
|
||||
## [2.78.0] — 2026-10-25
|
||||
|
||||
### Modifié
|
||||
|
||||
- **Dev/prod sans noms publics en dur (#223, Fusion)** — toutes les URL
|
||||
publiques de l'écosystème dracodev viennent désormais de la
|
||||
configuration (`backend/draco_config.py` : `DRACO_AGENT_URL`,
|
||||
`DRACO_OBSIGATE_URL`, `DRACO_FLOWDECK_URL`, `DRACO_TIKTAK_URL`,
|
||||
`DRACO_KANBANTE_URL`, `DRACO_GITEA_URL` ; défauts = noms officiels,
|
||||
l'environnement dev déclare ses noms en `-dev` dans son seul `.env`).
|
||||
La CSP (`connect-src`/`frame-src`) dérive l'origine de l'agent de la
|
||||
config (à défaut, de l'origine d'`AGENT_EVENEMENTS_URL`) ; le backend
|
||||
injecte `window.DRACO_URLS` dans `index.html` pour le panneau Spark,
|
||||
la barre d'apps et les liens inter-apps ; les références croisées
|
||||
reconnaissent les hôtes officiels et configurés et leur URL
|
||||
d'affichage est reconstruite depuis la config à la lecture (aucune
|
||||
donnée stockée migrée). WebAuthn confirmé dérivé de la requête /
|
||||
`OBSIGATE_WEBAUTHN_*` (aucun changement). Redéploiement Docker
|
||||
requis ; en dev, déclarer les noms en `-dev` dans le `.env` du poste.
|
||||
|
||||
---
|
||||
|
||||
## [2.77.1] — 2026-10-24
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Panneau Spark : « mode console » bloqué par la CSP (BUG-119, Fusion)** —
|
||||
la surimpression du panneau Spark V3 (#222) charge la console du service
|
||||
d'agents (`https://agent.dracodev.net`) dans une iframe, mais la CSP servie
|
||||
limitait `frame-src` à `'self' blob:` : le navigateur refusait le
|
||||
chargement (« This content is blocked »). `frame-src` autorise désormais
|
||||
aussi `https://agent.dracodev.net` — déjà présente dans `connect-src`
|
||||
pour le panneau texte ; aucune autre directive n'est modifiée. Test de
|
||||
non-régression dans `tests/test_security_headers.py`. Redéploiement
|
||||
Docker requis pour l'activer côté ObsiGate.
|
||||
|
||||
---
|
||||
|
||||
## [2.77.0] — 2026-10-23
|
||||
|
||||
### Modifié
|
||||
|
||||
- **Panneau Spark V3 : la console en surimpression pleine page
|
||||
(#222, Fusion)** — le bouton « Console ⤢ » du panneau Spark
|
||||
(asset vendangé, octet-identique dans les 4 apps) n'ouvre plus
|
||||
la console web du service d'agents en nouvel onglet (#221) :
|
||||
elle s'ouvre **dans la même page**, en surimpression née à la
|
||||
largeur du panneau et étendue à toute la fenêtre par
|
||||
transition, avec le lien profond conservé (`?disc=<id>` et
|
||||
`?app=obsigate`). Sa barre offre les deux sorties : « Mode
|
||||
sidebar » (retour à l'agent en barre latérale) et « Fermer
|
||||
l'agent ». Les styles sont injectés par le panneau lui-même ;
|
||||
côté service (0.20.0), la console autorise l'encadrement par
|
||||
les 4 applications. Même livraison dans TikTak 0.60.0,
|
||||
Flowdeck 7.81.0 et Kanbante 0.29.0. Redéploiement Docker
|
||||
requis pour l'activer côté ObsiGate.
|
||||
|
||||
---
|
||||
|
||||
## [2.76.1] — 2026-10-22
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Panneau Spark : bouton « Console ↗ » (#221, Fusion)** — la
|
||||
barre des discussions du panneau Spark (asset vendangé,
|
||||
octet-identique dans les 4 apps) gagne un bouton « Console ↗ »
|
||||
qui ouvre la console web du service d'agents
|
||||
(https://agent.dracodev.net/) dans un nouvel onglet, sur la
|
||||
discussion actuellement affichée (`?disc=<id>`, URL construite
|
||||
au clic) et avec l'app d'origine (`?app=obsigate`, déduite du
|
||||
sous-domaine `og`). Même livraison dans TikTak 0.59.1,
|
||||
Flowdeck 7.80.1 et Kanbante 0.28.1. Redéploiement Docker
|
||||
requis pour l'activer côté ObsiGate.
|
||||
|
||||
---
|
||||
|
||||
## [2.76.0] — 2026-10-22
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Émission d'événements vers le journal du service d'agents
|
||||
(#220, Fusion — Lot 2 vague 5)** — ObsiGate émet ses mutations de
|
||||
notes **explicites** au journal d'activité du service d'agents
|
||||
(contrat ACC-0.2) : `obsigate.note.created / edited / moved /
|
||||
deleted`, enveloppes signées HMAC-SHA256 (clé
|
||||
`AGENT_EVENEMENTS_CLE`), envoyées en arrière-plan sans jamais
|
||||
bloquer le métier, inactives tant que la clé n'est pas posée.
|
||||
Points d'accroche : les endpoints d'écriture de l'API web (acteur
|
||||
`user`) et la couche outils / serveur MCP (acteur `agent:mcp`).
|
||||
**Garde-fou : jamais le watcher** — les modifications faites hors
|
||||
API (Obsidian, éditeur externe) n'émettent rien. Domaine par vault
|
||||
via `AGENT_EVENEMENTS_DOMAINES`, repli `pro` (Q-A ouverte).
|
||||
Redéploiement Docker requis, puis pose de la clé des deux côtés
|
||||
pour activer.
|
||||
|
||||
---
|
||||
|
||||
## [2.75.0] — 2026-10-22
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Panneau Spark v2 (#219, Fusion — Lot 1)** — les assets partagés
|
||||
`frontend/draco-panneau.js` / `frontend/draco-panneau.css` passent
|
||||
en v2, à l'identique dans les quatre applications : réponses de
|
||||
l'agent en **Markdown rendu** (HTML produit par le service
|
||||
d'agents 0.10.0, sanitizé côté serveur), **largeur réglable**
|
||||
persistée, **thème clair/sombre** aligné sur l'application hôte
|
||||
(suivi en direct de `html[data-theme]`), et **discussions façon
|
||||
Muse** — discussion principale perpétuelle et discussions
|
||||
parallèles créées, archivées, supprimées depuis le tiroir du
|
||||
panneau. Aucun code propre à ObsiGate n'est modifié ;
|
||||
redéploiement Docker requis.
|
||||
|
||||
---
|
||||
|
||||
## [2.74.0] — 2026-10-22
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Panneau Spark intégré (#218, Fusion)** — le bouton Spark de la
|
||||
barre commune ouvre le panneau de l'agent commun (conversation,
|
||||
Aujourd'hui, approbations, missions longues), comme dans TikTak,
|
||||
Flowdeck et Kanbante : assets partagés `frontend/draco-panneau.js`
|
||||
et `frontend/draco-panneau.css` vendangés à l'identique, action
|
||||
`spark` branchée sur `DRACO_PANNEAU.toggle()`. La CSP nomme
|
||||
`https://agent.dracodev.net` en `connect-src` (seul hôte ajouté).
|
||||
Redéploiement Docker requis.
|
||||
|
||||
---
|
||||
|
||||
## [2.73.1] — 2026-10-21
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Barre commune v0.1.4 — palier mobile élargi de 700 à 900 px**
|
||||
(suite de BUG-118) : entre les deux seuils, la barre complète
|
||||
ne tenait pas (fenêtres réduites, tablettes, webviews) alors
|
||||
que les apps sont déjà en mise en page mobile ; le resserré
|
||||
(app courante, alertes, Spark, avatar) s'applique maintenant
|
||||
dès 900 px, et le décalage de `.app-container` suit le même
|
||||
seuil. Le changement d'app reste assuré par le menu du ✦.
|
||||
|
||||
---
|
||||
|
||||
## [2.73.0] — 2026-10-21
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Menu des applications par la marque ✦** (barre commune
|
||||
**v0.1.3**, #215) : cliquer le ✦ — un peu plus gros (24 px) —
|
||||
ouvre le menu des 4 applications (liens, icônes, badges), sur
|
||||
mobile comme sur bureau. C'est le changement d'app en mobile,
|
||||
où les onglets sont repliés depuis la v0.1.2. Fermeture au
|
||||
clic extérieur ou à Échap. Choix de Bruno (2026-10-21).
|
||||
|
||||
---
|
||||
|
||||
## [2.72.1] — 2026-10-21
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-118 — barre d'apps Fusion en mobile** : la barre commune
|
||||
passe en **v0.1.2** (dracodev-commons) — au palier ≤ 700 px elle
|
||||
ne garde que la marque, l'onglet de l'app courante, les alertes,
|
||||
Spark et l'avatar (avant : onglets et pastilles domaines entiers,
|
||||
barre débordante sur téléphone). Le bouton ☰ de la barre pilote
|
||||
désormais le bon bouton selon la largeur : barre latérale bureau
|
||||
au-delà de 768 px, **vrai hamburger mobile** (tiroir) en dessous —
|
||||
avant, il cliquait le bouton bureau et ne faisait rien sur
|
||||
téléphone, donnant l'impression d'un hamburger mort à côté de
|
||||
celui d'ObsiGate. Test : `tests/test_draco_barre.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.72.0] — 2026-10-20
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Fusion ACC-3.2 (#217) — validation locale du jeton
|
||||
`draco_session`** : `get_current_user` accepte le cookie partagé à
|
||||
défaut de jeton natif valide ; le `sub` étant l'identifiant
|
||||
ObsiGate, la correspondance vers le compte local est directe
|
||||
(compte actif et garde-fou « changement de mot de passe » exigés).
|
||||
Fiche : `docs/features/fusion-acc32-validation-locale-217.md`.
|
||||
|
||||
---
|
||||
|
||||
## [2.71.0] — 2026-10-20
|
||||
|
||||
### Ajouté
|
||||
|
||||
+3
-3
@@ -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.71.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.71.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.81.1 | 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.71.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.71.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.81.1 | Last updated: September 2026*
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -244,7 +244,7 @@ def _build_stats_payload() -> dict:
|
||||
"disk_used_gb": disk_used_gb,
|
||||
"disk_total_gb": disk_total_gb,
|
||||
"uptime_seconds": int(_server_uptime_seconds()),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Émission d'événements vers le journal du service d'agents
|
||||
(Fusion, Lot 2 vague 5 — contrat ACC-0.2, Roadmap #220).
|
||||
|
||||
ObsiGate émet ses mutations de notes **explicites** vers
|
||||
``POST /api/v1/evenements`` du service d'agents, qui les expose dans
|
||||
le journal de sa console. L'enveloppe ne contient qu'une référence,
|
||||
un libellé et un lien — jamais le contenu de la note :
|
||||
``{id, type, occurred_at, actor, domain, source_app,
|
||||
object{ref, label, url}}``.
|
||||
|
||||
**Garde-fou (conception validée)** : ce module n'est appelé que
|
||||
depuis les points d'écriture explicites de l'API web —
|
||||
:mod:`backend.routers.files_write` (session utilisateur, acteur
|
||||
``user``) et la couche outils :mod:`backend.tools.service`
|
||||
(serveur MCP d'ObsiGate, acteur ``agent:mcp`` ; assistant intégré,
|
||||
acteur ``user``). Le **watcher** (:mod:`backend.watcher`) et
|
||||
l'indexeur ne l'appellent **jamais** : le vault principal compte
|
||||
plus de 220 000 fichiers, un branchement sur les événements du
|
||||
système de fichiers inonderait le journal. Une modification faite
|
||||
hors API (éditeur externe, synchro disque…) reste donc invisible
|
||||
du journal — c'est voulu.
|
||||
|
||||
Authentification : signature HMAC-SHA256 du corps brut, en-tête
|
||||
``X-Draco-Signature``, clé propre à ObsiGate
|
||||
(``AGENT_EVENEMENTS_CLE``, jamais en dur). Sans clé, le module est
|
||||
**inactif** : aucun appel, aucun bruit. URL surchargée par
|
||||
``AGENT_EVENEMENTS_URL``.
|
||||
|
||||
Discipline « au mieux » (même esprit que TikTak et Kanbante) :
|
||||
l'envoi part dans un fil d'arrière-plan démon, délai maximal 2 s,
|
||||
et toute erreur est avalée avec une ligne de journal — une panne
|
||||
du récepteur ne doit jamais casser ni ralentir le métier.
|
||||
|
||||
Domaine : ObsiGate ne modélise pas encore le domaine d'un vault
|
||||
(question Q-A du programme Fusion toujours ouverte). La
|
||||
correspondance peut être déclarée par ``AGENT_EVENEMENTS_DOMAINES``
|
||||
(JSON ``{"NomDuVault": "famille"}``) ; repli ``pro``, qui est aussi
|
||||
le défaut du journal du service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("obsigate.agent_journal")
|
||||
|
||||
SOURCE_APP = "obsigate"
|
||||
# Défaut officiel, conservé pour référence : depuis #223, ``_url()``
|
||||
# passe par la config centralisée (``backend.draco_config``) — jamais
|
||||
# un nom d'environnement particulier.
|
||||
URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements"
|
||||
DELAI_S = 2.0
|
||||
|
||||
TYPE_CREE = "obsigate.note.created"
|
||||
TYPE_EDITE = "obsigate.note.edited"
|
||||
TYPE_DEPLACE = "obsigate.note.moved"
|
||||
TYPE_SUPPRIME = "obsigate.note.deleted"
|
||||
|
||||
ACTEUR_USER = "user"
|
||||
ACTEUR_MCP = "agent:mcp"
|
||||
|
||||
|
||||
def _cle() -> str:
|
||||
return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip()
|
||||
|
||||
|
||||
def _url() -> str:
|
||||
# #223 : AGENT_EVENEMENTS_URL d'abord ; à défaut, l'URL du service
|
||||
# configurée (DRACO_AGENT_URL) complétée du chemin d'événements ;
|
||||
# à défaut, le défaut officiel.
|
||||
explicite = os.environ.get("AGENT_EVENEMENTS_URL", "").strip()
|
||||
if explicite:
|
||||
return explicite
|
||||
from backend.draco_config import agent_url
|
||||
|
||||
return f"{agent_url()}/api/v1/evenements"
|
||||
|
||||
|
||||
def actif() -> bool:
|
||||
"""L'émission n'existe que si la clé d'ObsiGate est configurée."""
|
||||
return bool(_cle())
|
||||
|
||||
|
||||
def domaine_du_vault(vault: str) -> str:
|
||||
"""Domaine journal du vault : correspondance déclarée dans
|
||||
``AGENT_EVENEMENTS_DOMAINES`` (JSON), repli ``pro``."""
|
||||
brut = os.environ.get("AGENT_EVENEMENTS_DOMAINES", "").strip()
|
||||
if brut:
|
||||
try:
|
||||
correspondance = json.loads(brut)
|
||||
except ValueError:
|
||||
correspondance = {}
|
||||
if isinstance(correspondance, dict):
|
||||
domaine = correspondance.get(vault)
|
||||
if isinstance(domaine, str) and domaine:
|
||||
return domaine
|
||||
return "pro"
|
||||
|
||||
|
||||
def lien_fichier(vault: str, chemin: str) -> str:
|
||||
"""Lien profond ObsiGate vers un fichier (format ``#file=`` lu
|
||||
par le frontend : ``#file=<vault>:<chemin>``, décodé en URI)."""
|
||||
return f"/#file={quote(vault, safe='')}:{quote(chemin, safe='/')}"
|
||||
|
||||
|
||||
def construire_enveloppe(
|
||||
type_: str,
|
||||
vault: str,
|
||||
chemin: str,
|
||||
actor: str = ACTEUR_USER,
|
||||
) -> tuple[dict, bytes]:
|
||||
"""L'enveloppe ACC-0.2 et son corps JSON exact (celui qui est
|
||||
signé puis envoyé — la signature porte sur ces octets-là).
|
||||
``chemin`` est le chemin relatif au vault ; pour un déplacement,
|
||||
c'est le **nouveau** chemin (la référence pointe sur l'objet tel
|
||||
qu'il existe après l'opération)."""
|
||||
env = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"type": type_,
|
||||
"occurred_at": datetime.now(UTC).isoformat(),
|
||||
"actor": actor or ACTEUR_USER,
|
||||
"domain": domaine_du_vault(vault),
|
||||
"source_app": SOURCE_APP,
|
||||
"object": {
|
||||
"ref": f"fichier:{vault}/{chemin}",
|
||||
"label": chemin.rsplit("/", 1)[-1] if chemin else vault,
|
||||
"url": lien_fichier(vault, chemin),
|
||||
},
|
||||
}
|
||||
corps = json.dumps(env, ensure_ascii=False).encode("utf-8")
|
||||
return env, corps
|
||||
|
||||
|
||||
def _signer(corps: bytes) -> str:
|
||||
return hmac.new(_cle().encode("utf-8"), corps, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def _envoyer(corps: bytes) -> None:
|
||||
"""Le POST signé, dans le fil d'arrière-plan. Avale tout : le
|
||||
journal distant est un confort, jamais une dépendance."""
|
||||
try:
|
||||
with httpx.Client(timeout=DELAI_S, trust_env=False) as client:
|
||||
client.post(
|
||||
_url(),
|
||||
content=corps,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Draco-Signature": _signer(corps),
|
||||
},
|
||||
)
|
||||
except Exception as exc: # panne, délai, DNS… : on log et on passe
|
||||
logger.warning("événement non livré au journal du service (%s)", exc)
|
||||
|
||||
|
||||
def emettre(
|
||||
type_: str,
|
||||
vault: str,
|
||||
chemin: str,
|
||||
actor: str = ACTEUR_USER,
|
||||
) -> bool:
|
||||
"""Émet un événement en tâche de fond. Retourne True si l'envoi
|
||||
a été déclenché, False si le module est inactif (pas de clé) ou
|
||||
si l'enveloppe n'a pas pu être construite. Ne lève jamais."""
|
||||
if not actif():
|
||||
return False
|
||||
try:
|
||||
_env, corps = construire_enveloppe(type_, vault, chemin, actor)
|
||||
except Exception:
|
||||
logger.warning("événement %s non construit", type_, exc_info=True)
|
||||
return False
|
||||
threading.Thread(
|
||||
target=_envoyer, args=(corps,), daemon=True, name="obsigate-evenements"
|
||||
).start()
|
||||
return True
|
||||
+41
-6
@@ -10,7 +10,7 @@ Format: JSON lines (one JSON object per line) for easy parsing.
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.audit")
|
||||
@@ -54,7 +54,7 @@ def log_file_save(
|
||||
):
|
||||
"""Log a file save (PUT) operation."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": "file_save",
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
@@ -72,7 +72,7 @@ def log_file_delete(
|
||||
):
|
||||
"""Log a file delete operation."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": "file_delete",
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
@@ -88,7 +88,7 @@ def log_config_change(
|
||||
):
|
||||
"""Log a configuration change."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": "config_change",
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
@@ -99,7 +99,7 @@ def log_config_change(
|
||||
def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | None = None):
|
||||
"""Log a vault addition."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": "vault_add",
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
@@ -111,7 +111,7 @@ def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | Non
|
||||
def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
|
||||
"""Log a vault removal."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": "vault_remove",
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
@@ -119,6 +119,41 @@ def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
|
||||
})
|
||||
|
||||
|
||||
def log_coffre_event(
|
||||
username: str,
|
||||
action: str,
|
||||
coffre: str | None = None,
|
||||
secret_ref: str | None = None,
|
||||
consommateur: str | None = None,
|
||||
resultat: str | None = None,
|
||||
contexte_id: str | None = None,
|
||||
ip: str | None = None,
|
||||
):
|
||||
"""Journal d'un événement du coffre Secrets (Phase 1b).
|
||||
|
||||
Règle absolue : aucun champ ne contient jamais une valeur de secret,
|
||||
un texte chiffré, un nonce ou un jeton. Seules des références
|
||||
(coffre:<slug>/<nom>), des identifiants et des codes de résultat.
|
||||
"""
|
||||
entry = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": action,
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
}
|
||||
if coffre is not None:
|
||||
entry["coffre"] = coffre
|
||||
if secret_ref is not None:
|
||||
entry["secret_ref"] = secret_ref
|
||||
if consommateur is not None:
|
||||
entry["consommateur"] = consommateur
|
||||
if resultat is not None:
|
||||
entry["resultat"] = resultat
|
||||
if contexte_id is not None:
|
||||
entry["contexte_id"] = contexte_id
|
||||
_write_entry(entry)
|
||||
|
||||
|
||||
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
|
||||
"""Read the most recent audit log entries.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import sys
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from backend import draco_session
|
||||
from backend.services.net import get_client_ip
|
||||
|
||||
from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token
|
||||
@@ -86,20 +87,22 @@ def get_current_user(
|
||||
token = request.cookies["access_token"]
|
||||
|
||||
if not token:
|
||||
return None
|
||||
return _draco_fallback(request)
|
||||
|
||||
payload = decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
return None
|
||||
return _draco_fallback(request)
|
||||
|
||||
# BUG-027: access tokens revoked at logout must be rejected immediately.
|
||||
# (Le repli draco reste sûr : la déconnexion globale révoque aussi
|
||||
# le jti draco et efface son cookie.)
|
||||
jti = payload.get("jti")
|
||||
if jti and is_token_revoked(jti):
|
||||
return None
|
||||
return _draco_fallback(request)
|
||||
|
||||
user = get_user(payload["sub"])
|
||||
if not user or not user.get("active"):
|
||||
return None
|
||||
return _draco_fallback(request)
|
||||
|
||||
# BUG-028: a password change invalidates every token issued before it.
|
||||
pca = user.get("password_changed_at")
|
||||
@@ -107,9 +110,9 @@ def get_current_user(
|
||||
if pca is not None and iat is not None:
|
||||
try:
|
||||
if int(iat) < int(float(pca)):
|
||||
return None
|
||||
return _draco_fallback(request)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return _draco_fallback(request)
|
||||
|
||||
# Attach vault permissions from the token (snapshot at login time)
|
||||
user["_token_vaults"] = payload.get("vaults", [])
|
||||
@@ -127,6 +130,17 @@ def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
def _draco_fallback(request: Request) -> dict | None:
|
||||
"""Fusion ACC-3.2 (#9) : à défaut de jeton natif valide, le cookie
|
||||
partagé draco_session authentifie l'utilisateur (sub = id
|
||||
ObsiGate, correspondance directe — voir draco_session.user_for_draco).
|
||||
"""
|
||||
user = draco_session.user_for_draco(request)
|
||||
if user is not None:
|
||||
user["_request_ip"] = get_client_ip(request)
|
||||
return user
|
||||
|
||||
|
||||
def require_auth(current_user=Depends(get_current_user)):
|
||||
"""Dependency: require a valid authenticated user."""
|
||||
if not current_user:
|
||||
|
||||
@@ -7,6 +7,7 @@ import binascii
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, validator
|
||||
@@ -696,7 +697,7 @@ async def mfa_webauthn_register(
|
||||
|
||||
Returns recovery codes when MFA is newly enabled (they were never issued).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import complete_registration, resolve_relying_party
|
||||
@@ -716,7 +717,7 @@ async def mfa_webauthn_register(
|
||||
logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
|
||||
raise HTTPException(400, "Validation du credential WebAuthn échouée")
|
||||
|
||||
record["registered_at"] = datetime.now(timezone.utc).isoformat()
|
||||
record["registered_at"] = datetime.now(UTC).isoformat()
|
||||
creds = list(user.get("webauthn_credentials", []))
|
||||
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
|
||||
creds.append(record)
|
||||
|
||||
@@ -8,7 +8,7 @@ import logging
|
||||
import shutil
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from .password import hash_password
|
||||
@@ -97,8 +97,8 @@ def create_user(
|
||||
"active": True,
|
||||
"language": "fr", # default UI language
|
||||
"avatar": None, # profile picture data-URL (#113)
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"password_changed_at": datetime.now(timezone.utc).timestamp(),
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"password_changed_at": datetime.now(UTC).timestamp(),
|
||||
"last_login": None,
|
||||
"failed_attempts": 0,
|
||||
"locked_until": None,
|
||||
@@ -126,7 +126,7 @@ def update_user(username: str, updates: dict) -> dict:
|
||||
if "password" in safe_updates:
|
||||
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
|
||||
# BUG-028: invalidate every token issued before this change.
|
||||
safe_updates["password_changed_at"] = datetime.now(timezone.utc).timestamp()
|
||||
safe_updates["password_changed_at"] = datetime.now(UTC).timestamp()
|
||||
|
||||
data["users"][username].update(safe_updates)
|
||||
_write(data)
|
||||
@@ -148,7 +148,7 @@ def delete_user(username: str):
|
||||
def record_login_success(username: str):
|
||||
"""Record a successful login: update last_login, reset failed attempts."""
|
||||
update_user(username, {
|
||||
"last_login": datetime.now(timezone.utc).isoformat(),
|
||||
"last_login": datetime.now(UTC).isoformat(),
|
||||
"failed_attempts": 0,
|
||||
"locked_until": None,
|
||||
})
|
||||
@@ -171,7 +171,7 @@ def record_login_failure(username: str) -> int:
|
||||
# Lock after 5 failed attempts (15 minutes)
|
||||
if attempts >= 5:
|
||||
locked_until = (
|
||||
datetime.now(timezone.utc) + timedelta(minutes=15)
|
||||
datetime.now(UTC) + timedelta(minutes=15)
|
||||
).isoformat()
|
||||
updates["locked_until"] = locked_until
|
||||
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
|
||||
@@ -189,7 +189,7 @@ def is_locked(username: str) -> bool:
|
||||
if not user or not user.get("locked_until"):
|
||||
return False
|
||||
locked_until = datetime.fromisoformat(user["locked_until"])
|
||||
if datetime.now(timezone.utc) > locked_until:
|
||||
if datetime.now(UTC) > locked_until:
|
||||
# Lock expired — unlock
|
||||
update_user(username, {"locked_until": None, "failed_attempts": 0})
|
||||
return False
|
||||
|
||||
@@ -591,7 +591,7 @@ async def api_bookslm_agent(
|
||||
while True:
|
||||
try:
|
||||
kind, item = await asyncio.wait_for(queue.get(), timeout=0.25)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
if run_task.done():
|
||||
break
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Chiffrement par enveloppe du coffre Secrets ObsiGate (Phase 1a).
|
||||
|
||||
Principe (spec §5) : deux niveaux de clés, jamais de valeur en clair au repos.
|
||||
|
||||
- KEK : clé maître 256 bits, fournie par l'opérateur via ``OBSIGATE_KEK``
|
||||
(base64, 32 octets) ou ``OBSIGATE_KEK_FILE`` (fichier 0600, hors dépôt,
|
||||
hors arborescence des vaults). Elle n'est JAMAIS dérivée du secret
|
||||
d'application, jamais journalisée, jamais renvoyée par API.
|
||||
- DEK : une clé aléatoire par coffre, stockée uniquement chiffrée par la KEK.
|
||||
- Version de secret : ciphertext AES-256-GCM + nonce unique + tag GCM
|
||||
(le tag est inclus dans le ciphertext renvoyé par ``AESGCM.encrypt``).
|
||||
Un tag invalide ou un fichier altéré = refus, jamais de valeur fausse.
|
||||
|
||||
Le module reste importable sans ``cryptography`` installé : les opérations
|
||||
échouent alors avec ``CryptoIndisponibleError`` au lieu de casser le
|
||||
démarrage de l'application (le routeur répond 503 dans ce cas).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("obsigate.coffre_crypto")
|
||||
|
||||
# Annotation explicite : sans elle, mypy refuse l'assignation de deux types
|
||||
# (la classe AESGCM importée, puis None dans le repli sans cryptography)
|
||||
# et le commentaire `type: ignore[assignment]` ne couvre pas ce code [misc].
|
||||
AESGCM: type[Any] | None
|
||||
try: # cryptography est une dépendance de la Phase 1a (requirements.txt)
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
except Exception: # pragma: no cover - environnement sans cryptography
|
||||
AESGCM = None
|
||||
InvalidTag = Exception # type: ignore[assignment,misc]
|
||||
|
||||
|
||||
class CoffreCryptoError(Exception):
|
||||
"""Erreur générique du chiffrement du coffre."""
|
||||
|
||||
|
||||
class KEKAbsenteError(CoffreCryptoError):
|
||||
"""Aucune KEK configurée (ni OBSIGATE_KEK, ni OBSIGATE_KEK_FILE)."""
|
||||
|
||||
|
||||
class KEKInvalideError(CoffreCryptoError):
|
||||
"""KEK mal formée (ni base64 32 octets, ni hex 64 caractères)."""
|
||||
|
||||
|
||||
class TagInvalideError(CoffreCryptoError):
|
||||
"""Authentification GCM échouée : données altérées ou mauvaise clé."""
|
||||
|
||||
|
||||
class CryptoIndisponibleError(CoffreCryptoError):
|
||||
"""La bibliothèque ``cryptography`` n'est pas installée."""
|
||||
|
||||
|
||||
def _require_aesgcm() -> type[Any]:
|
||||
"""Retourne la classe AESGCM, ou lève si cryptography est absent.
|
||||
|
||||
Retourner la classe (plutôt qu'un simple garde) permet à mypy de
|
||||
savoir que l'appelant détient bien un constructible, et non None.
|
||||
"""
|
||||
if AESGCM is None:
|
||||
raise CryptoIndisponibleError(
|
||||
"cryptography n'est pas installé (dépendance Phase 1a)"
|
||||
)
|
||||
return AESGCM
|
||||
|
||||
|
||||
def parse_kek(raw: str) -> bytes:
|
||||
"""Parse une KEK : base64 (32 octets) ou hex (64 caractères).
|
||||
|
||||
Accepte le base64 standard et URL-safe, avec ou sans padding.
|
||||
"""
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
raise KEKInvalideError("KEK vide")
|
||||
# Hex d'abord si la forme correspond (64 caractères hex).
|
||||
if len(value) == 64:
|
||||
try:
|
||||
decoded = bytes.fromhex(value)
|
||||
except ValueError:
|
||||
decoded = b""
|
||||
if len(decoded) == 32:
|
||||
return decoded
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
for decoder in (base64.urlsafe_b64decode, base64.b64decode):
|
||||
try:
|
||||
decoded = decoder(padded)
|
||||
except (binascii.Error, ValueError):
|
||||
continue
|
||||
if len(decoded) == 32:
|
||||
return decoded
|
||||
raise KEKInvalideError("KEK invalide : attendu base64 de 32 octets")
|
||||
|
||||
|
||||
def generate_kek_b64() -> str:
|
||||
"""Génère une KEK 256 bits, encodée base64 (à sauvegarder hors dépôt)."""
|
||||
return base64.b64encode(os.urandom(32)).decode("ascii")
|
||||
|
||||
|
||||
def load_kek() -> bytes:
|
||||
"""Charge la KEK depuis l'environnement ou le fichier dédié.
|
||||
|
||||
Priorité : ``OBSIGATE_KEK`` puis ``OBSIGATE_KEK_FILE``. Le fichier doit
|
||||
être en 0600 (aucun droit groupe/autre) ; sinon, refus explicite.
|
||||
"""
|
||||
env_kek = os.environ.get("OBSIGATE_KEK", "").strip()
|
||||
if env_kek:
|
||||
return parse_kek(env_kek)
|
||||
kek_file = os.environ.get("OBSIGATE_KEK_FILE", "").strip()
|
||||
if kek_file:
|
||||
path = Path(kek_file)
|
||||
try:
|
||||
mode = path.stat().st_mode
|
||||
except OSError as exc:
|
||||
raise KEKAbsenteError(f"Fichier KEK illisible : {kek_file}") from exc
|
||||
if mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||
raise KEKInvalideError(
|
||||
"Fichier KEK trop permissif : attendu 0600, "
|
||||
f"droits groupe/autre détectés sur {kek_file}"
|
||||
)
|
||||
try:
|
||||
return parse_kek(path.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
raise KEKAbsenteError(f"Fichier KEK illisible : {kek_file}") from exc
|
||||
raise KEKAbsenteError("Aucune KEK configurée (OBSIGATE_KEK / OBSIGATE_KEK_FILE)")
|
||||
|
||||
|
||||
def kek_presente() -> bool:
|
||||
"""True si une KEK valide est chargeable, False sinon (jamais d'exception)."""
|
||||
try:
|
||||
load_kek()
|
||||
except CoffreCryptoError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def generate_dek() -> bytes:
|
||||
"""Génère une DEK aléatoire 256 bits pour un coffre."""
|
||||
return os.urandom(32)
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
return base64.b64encode(data).decode("ascii")
|
||||
|
||||
|
||||
def _unb64(value: str) -> bytes:
|
||||
return base64.b64decode(value)
|
||||
|
||||
|
||||
def encrypt_with_key(key: bytes, plaintext: bytes, aad: bytes) -> dict:
|
||||
"""Chiffre avec AES-256-GCM. Retourne {nonce, ciphertext} en base64."""
|
||||
aesgcm = _require_aesgcm()
|
||||
nonce = os.urandom(12)
|
||||
ciphertext = aesgcm(key).encrypt(nonce, plaintext, aad)
|
||||
return {"nonce": _b64(nonce), "ciphertext": _b64(ciphertext)}
|
||||
|
||||
|
||||
def decrypt_with_key(key: bytes, nonce_b64: str, ciphertext_b64: str, aad: bytes) -> bytes:
|
||||
"""Déchiffre avec AES-256-GCM. Tag invalide -> TagInvalideError."""
|
||||
aesgcm = _require_aesgcm()
|
||||
try:
|
||||
return aesgcm(key).decrypt(_unb64(nonce_b64), _unb64(ciphertext_b64), aad)
|
||||
except InvalidTag as exc:
|
||||
raise TagInvalideError("Tag GCM invalide : données altérées ou mauvaise clé") from exc
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise TagInvalideError("Données chiffrées mal formées") from exc
|
||||
|
||||
|
||||
def wrap_dek(dek: bytes, kek: bytes, coffre_id: str) -> dict:
|
||||
"""Chiffre une DEK avec la KEK (AAD = identifiant du coffre)."""
|
||||
return encrypt_with_key(kek, dek, coffre_id.encode("utf-8"))
|
||||
|
||||
|
||||
def unwrap_dek(wrapped: dict, kek: bytes, coffre_id: str) -> bytes:
|
||||
"""Déchiffre une DEK avec la KEK."""
|
||||
return decrypt_with_key(
|
||||
kek, wrapped["nonce"], wrapped["ciphertext"], coffre_id.encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
def encrypt_secret_value(dek: bytes, secret_id: str, version: int, valeur: str) -> dict:
|
||||
"""Chiffre une valeur de secret (AAD = secret_id:version)."""
|
||||
aad = f"{secret_id}:{version}".encode()
|
||||
return encrypt_with_key(dek, valeur.encode("utf-8"), aad)
|
||||
|
||||
|
||||
def decrypt_secret_value(
|
||||
dek: bytes, secret_id: str, version: int, nonce_b64: str, ciphertext_b64: str
|
||||
) -> str:
|
||||
"""Déchiffre une valeur de secret."""
|
||||
aad = f"{secret_id}:{version}".encode()
|
||||
return decrypt_with_key(dek, nonce_b64, ciphertext_b64, aad).decode("utf-8")
|
||||
|
||||
|
||||
def reencrypt_secret_value(
|
||||
ancienne_dek: bytes,
|
||||
nouvelle_dek: bytes,
|
||||
secret_id: str,
|
||||
version: int,
|
||||
nonce_b64: str,
|
||||
ciphertext_b64: str,
|
||||
) -> dict:
|
||||
"""Re-chiffre une version avec une nouvelle DEK (rotation DEK)."""
|
||||
valeur = decrypt_secret_value(
|
||||
ancienne_dek, secret_id, version, nonce_b64, ciphertext_b64
|
||||
)
|
||||
return encrypt_secret_value(nouvelle_dek, secret_id, version, valeur)
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Migration Phase 2 du coffre Secrets ObsiGate (issue #20).
|
||||
|
||||
Bascule des clés aujourd'hui en clair dans ``data/api_keys.json`` vers le
|
||||
coffre d'instance « ObsiGate interne » (slug ``obsigate-interne``).
|
||||
|
||||
Format source (constaté dans ``backend/ai.py`` et
|
||||
``backend/tools/secrets.py``) : un dictionnaire plat ``{NOM: valeur}``.
|
||||
Seules les entrées dont le nom ressemble à un secret sont migrables :
|
||||
clés IA et clés d'outils. Les valeurs non secrètes (par exemple
|
||||
``OBSIGATE_GITEA_URL``) ne sont JAMAIS migrées et ne sont jamais
|
||||
touchées par la purge.
|
||||
|
||||
Règles de sécurité, non négociables :
|
||||
- Aucune fonction de ce module ne renvoie une valeur, ne la journalise,
|
||||
ni ne l'écrit ailleurs que dans le coffre (chiffrée, via le store).
|
||||
Les aperçus ne contiennent que des noms, des types et ``has_value``.
|
||||
- L'import ne supprime rien : il crée des secrets dans le coffre et
|
||||
laisse ``api_keys.json`` intact. La purge est une étape séparée, qui
|
||||
exige ``confirmer=True`` et ne supprime que les noms listés.
|
||||
- Un secret déjà présent dans le coffre n'est JAMAIS écrasé : il est
|
||||
signalé dans ``deja_existants`` et la valeur du coffre est conservée.
|
||||
|
||||
Limite connue, documentée dans ``docs/COFFRE-sauvegarde-restauration.md`` :
|
||||
les lecteurs actuels (``backend/ai.py``, ``backend/tools/secrets.py``)
|
||||
lisent encore ``api_keys.json`` (chemin relatif ``data/``, sans passer
|
||||
par ``OBSIGATE_DATA_DIR``). Purger le clair sans avoir branché le
|
||||
lecteur concerné sur le coffre rend donc la clé inaccessible à ce
|
||||
lecteur : la purge reste un choix explicite de l'opérateur, confirmé
|
||||
deux fois côté interface (import, puis purge).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.coffre_store import (
|
||||
ValidationError,
|
||||
create_coffre,
|
||||
create_secret,
|
||||
get_coffre,
|
||||
get_secret,
|
||||
resolve_data_dir,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.coffre_migration")
|
||||
|
||||
_lock = threading.RLock()
|
||||
|
||||
COFFRE_INTERNE_SLUG = "obsigate-interne"
|
||||
COFFRE_INTERNE_NOM = "ObsiGate interne"
|
||||
|
||||
# (nom dans api_keys.json, type de secret, service d'affichage).
|
||||
# Miroir des clés lues par backend/ai.py (clés IA) et par
|
||||
# backend/tools/secrets.py TOOL_KEY_NAMES (clés d'outils). L'URL Gitea
|
||||
# est volontairement absente : ce n'est pas un secret.
|
||||
CLES_MIGRABLES: tuple[tuple[str, str, str], ...] = (
|
||||
("DEEPSEEK_API_KEY", "api_key", "DeepSeek"),
|
||||
("OPENROUTER_API_KEY", "api_key", "OpenRouter"),
|
||||
("GEMINI_API_KEY", "api_key", "Gemini"),
|
||||
("NVIDIA_API_KEY", "api_key", "NVIDIA"),
|
||||
("QWENCLOUD_API_KEY", "api_key", "QwenCloud"),
|
||||
("XIAOMI_API_KEY", "api_key", "Xiaomi"),
|
||||
("MISTRAL_API_KEY", "api_key", "Mistral"),
|
||||
("OBSIGATE_TAVILY_API_KEY", "api_key", "Tavily"),
|
||||
("OBSIGATE_BRAVE_API_KEY", "api_key", "Brave"),
|
||||
("OBSIGATE_SERPAPI_API_KEY", "api_key", "SerpAPI"),
|
||||
("OBSIGATE_EXA_API_KEY", "api_key", "Exa"),
|
||||
("OBSIGATE_GITEA_TOKEN", "token", "Gitea"),
|
||||
("OBSIGATE_GITHUB_TOKEN", "token", "GitHub"),
|
||||
)
|
||||
|
||||
_NOMS_MIGRABLES = {nom for nom, _, _ in CLES_MIGRABLES}
|
||||
|
||||
|
||||
def _nom_secret(nom_source: str) -> str:
|
||||
"""Nom de secret dérivé du nom source (minuscules, valide NOM_SECRET_RE)."""
|
||||
return nom_source.lower()
|
||||
|
||||
|
||||
def _api_keys_path(base: Path) -> Path:
|
||||
return base / "api_keys.json"
|
||||
|
||||
|
||||
def _read_api_keys(base: Path) -> dict:
|
||||
path = _api_keys_path(base)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("api_keys.json illisible (%s) : %s", path, exc)
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _write_api_keys(base: Path, data: dict) -> None:
|
||||
"""Écriture atomique tmp+rename, fichier final en 0600 (best-effort)."""
|
||||
path = _api_keys_path(base)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def lister_candidats(data_dir: str | Path | None = None) -> list:
|
||||
"""Aperçu de migration : métadonnées seules, jamais de valeur.
|
||||
|
||||
Chaque candidat porte ``nom`` (nom dans le coffre), ``source`` (nom
|
||||
dans api_keys.json), ``type``, ``service``, ``has_value`` (une valeur
|
||||
non vide est présente dans le fichier) et ``deja_existant`` (un
|
||||
secret de ce nom existe déjà dans le coffre d'instance).
|
||||
"""
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
cles = _read_api_keys(base)
|
||||
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
|
||||
candidats = []
|
||||
for source, type_secret, service in CLES_MIGRABLES:
|
||||
nom = _nom_secret(source)
|
||||
valeur = cles.get(source)
|
||||
candidats.append(
|
||||
{
|
||||
"nom": nom,
|
||||
"source": source,
|
||||
"type": type_secret,
|
||||
"service": service,
|
||||
"has_value": isinstance(valeur, str) and bool(valeur.strip()),
|
||||
"deja_existant": (
|
||||
get_secret(coffre["id"], nom, base) is not None
|
||||
if coffre is not None
|
||||
else False
|
||||
),
|
||||
}
|
||||
)
|
||||
return candidats
|
||||
|
||||
|
||||
def importer_api_keys(
|
||||
data_dir: str | Path | None = None, cree_par: str = ""
|
||||
) -> dict:
|
||||
"""Importe les entrées avec valeur vers le coffre d'instance.
|
||||
|
||||
Ne supprime rien dans ``api_keys.json``. Retourne uniquement des
|
||||
noms et des comptes : ``migres``, ``deja_existants``, ``sans_valeur``.
|
||||
Lève ``KEKAbsenteError`` / ``CryptoIndisponibleError`` si le coffre
|
||||
ne peut pas chiffrer (le routeur les traduit en 503).
|
||||
"""
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
cles = _read_api_keys(base)
|
||||
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
|
||||
if coffre is None:
|
||||
coffre = create_coffre(
|
||||
slug=COFFRE_INTERNE_SLUG,
|
||||
nom=COFFRE_INTERNE_NOM,
|
||||
proprietaire=(cree_par or "admin").strip(),
|
||||
perimetre="instance",
|
||||
description="Coffre d'instance : clés IA et clés d'outils migrées depuis api_keys.json (Phase 2).",
|
||||
data_dir=base,
|
||||
)
|
||||
migres: list[str] = []
|
||||
deja_existants: list[str] = []
|
||||
sans_valeur: list[str] = []
|
||||
for source, type_secret, service in CLES_MIGRABLES:
|
||||
nom = _nom_secret(source)
|
||||
valeur = cles.get(source)
|
||||
if not (isinstance(valeur, str) and valeur.strip()):
|
||||
sans_valeur.append(nom)
|
||||
continue
|
||||
if get_secret(coffre["id"], nom, base) is not None:
|
||||
deja_existants.append(nom)
|
||||
continue
|
||||
create_secret(
|
||||
coffre,
|
||||
nom,
|
||||
type_secret,
|
||||
valeur,
|
||||
service=service,
|
||||
note="Migré depuis api_keys.json (Phase 2).",
|
||||
data_dir=base,
|
||||
)
|
||||
migres.append(nom)
|
||||
return {
|
||||
"coffre": COFFRE_INTERNE_SLUG,
|
||||
"migres": migres,
|
||||
"deja_existants": deja_existants,
|
||||
"sans_valeur": sans_valeur,
|
||||
}
|
||||
|
||||
|
||||
def purger_api_keys(
|
||||
noms: list[str] | None,
|
||||
data_dir: str | Path | None = None,
|
||||
confirmer: bool = False,
|
||||
) -> dict:
|
||||
"""Supprime du clair les noms listés, après confirmation explicite.
|
||||
|
||||
``confirmer`` doit valoir True, sinon refus (jamais automatique).
|
||||
Seuls les noms migrables sont acceptés ; un nom inconnu refuse
|
||||
l'opération entière, pour ne jamais supprimer une clé par erreur.
|
||||
Retourne ``supprimes`` et ``absents`` (noms, jamais de valeurs).
|
||||
"""
|
||||
if not confirmer:
|
||||
raise ValidationError(
|
||||
"Suppression du clair refusée : confirmation explicite requise"
|
||||
)
|
||||
demandes = [(n or "").strip().lower() for n in (noms or [])]
|
||||
# Accepte indifféremment le nom source (DEEPSEEK_API_KEY) ou le nom
|
||||
# de secret (deepseek_api_key) ; tout le reste refuse l'opération.
|
||||
noms_secrets = {_nom_secret(source) for source in _NOMS_MIGRABLES}
|
||||
inconnus = [
|
||||
n
|
||||
for n in demandes
|
||||
if n.upper() not in _NOMS_MIGRABLES and n not in noms_secrets
|
||||
]
|
||||
if inconnus:
|
||||
raise ValidationError(f"Noms inconnus, purge annulée : {', '.join(inconnus)}")
|
||||
sources_a_supprimer = set()
|
||||
for demande in demandes:
|
||||
for source, _, _ in CLES_MIGRABLES:
|
||||
if demande in (source.lower(), _nom_secret(source)):
|
||||
sources_a_supprimer.add(source)
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
cles = _read_api_keys(base)
|
||||
supprimes: list[str] = []
|
||||
absents: list[str] = []
|
||||
for source in sorted(sources_a_supprimer):
|
||||
if source in cles:
|
||||
del cles[source]
|
||||
supprimes.append(_nom_secret(source))
|
||||
else:
|
||||
absents.append(_nom_secret(source))
|
||||
_write_api_keys(base, cles)
|
||||
return {"supprimes": supprimes, "absents": absents}
|
||||
|
||||
|
||||
def creer_consommateur_avec_octrois(
|
||||
nom: str,
|
||||
octrois_demandes: list[dict[str, Any]] | None,
|
||||
cree_par: str,
|
||||
data_dir: str | Path | None = None,
|
||||
) -> tuple[dict, str, list, list]:
|
||||
"""Enregistre un consommateur et ses octrois en une seule opération.
|
||||
|
||||
Retourne (consommateur public, jeton à usage unique, octrois créés,
|
||||
erreurs). Une erreur sur un octroi (coffre ou secret introuvable)
|
||||
n'annule pas le consommateur ni les autres octrois : elle est
|
||||
signalée dans ``erreurs`` (noms seuls, jamais de valeurs). Le coffre
|
||||
lui-même n'est jamais modifié par cette opération.
|
||||
"""
|
||||
from backend.coffre_store import ( # import local : évite un cycle
|
||||
IntrouvableError,
|
||||
create_consommateur,
|
||||
create_octroi,
|
||||
)
|
||||
|
||||
base = resolve_data_dir(data_dir)
|
||||
consommateur, jeton = create_consommateur(nom, cree_par, base)
|
||||
crees: list[dict] = []
|
||||
erreurs: list[dict] = []
|
||||
for demande in octrois_demandes or []:
|
||||
slug = str(demande.get("coffre_slug", "") or "")
|
||||
secret_nom = str(demande.get("secret_nom", "") or "") or None
|
||||
try:
|
||||
coffre = get_coffre(slug, base)
|
||||
if coffre is None:
|
||||
raise IntrouvableError(f"Coffre introuvable : {slug}")
|
||||
secret_id = None
|
||||
if secret_nom is not None:
|
||||
secret = get_secret(coffre["id"], secret_nom, base)
|
||||
if secret is None:
|
||||
raise IntrouvableError(f"Secret introuvable : {secret_nom}")
|
||||
secret_id = secret["id"]
|
||||
octroi = create_octroi(
|
||||
coffre=coffre,
|
||||
cible_type="consommateur",
|
||||
cible_id=consommateur["id"],
|
||||
secret_id=secret_id,
|
||||
cree_par=cree_par,
|
||||
expiration=demande.get("expiration"),
|
||||
data_dir=base,
|
||||
)
|
||||
crees.append(octroi)
|
||||
except (IntrouvableError, ValidationError) as exc:
|
||||
erreurs.append(
|
||||
{
|
||||
"coffre_slug": slug,
|
||||
"secret_nom": secret_nom,
|
||||
"erreur": str(exc),
|
||||
}
|
||||
)
|
||||
return consommateur, jeton, crees, erreurs
|
||||
@@ -0,0 +1,860 @@
|
||||
"""Store du coffre Secrets ObsiGate (Phase 1a).
|
||||
|
||||
Patron repris de ``backend/tools/secrets.py`` : JSON dans ``data/``,
|
||||
écriture atomique (tmp + rename), verrou ``RLock`` sur les cycles
|
||||
lecture-modification-écriture, répertoire honorant ``OBSIGATE_DATA_DIR``.
|
||||
|
||||
Fichiers (permissions 0600) :
|
||||
- ``data/coffres.json`` : coffres (métadonnées + DEK chiffrées),
|
||||
consommateurs (jetons hachés SHA-256 seulement, jamais en clair),
|
||||
octrois (références, statuts, expirations).
|
||||
- ``data/coffres_valeurs.json`` : versions chiffrées des secrets.
|
||||
|
||||
Aucune valeur en clair n'est jamais persistée ici, et aucune fonction de
|
||||
ce module ne journalise de valeur ni de texte chiffré.
|
||||
|
||||
Modèle :
|
||||
- Coffre : id, slug, nom, description, proprietaire, perimetre
|
||||
(personnel | partage | instance), dek_chiffree (ou None en mode
|
||||
dégradé sans KEK), statut (actif | archive), dates.
|
||||
- Secret : id, coffre_id, nom, type, service, note, expiration,
|
||||
version_courante, cree_le, modifie_le, derniere_utilisation.
|
||||
- Version : numero, nonce, ciphertext, cree_le, remplacee_le.
|
||||
Seule la version courante est distribuable.
|
||||
- Consommateur : id (sert de jti), nom, jeton_hash, jeton_prefixe,
|
||||
statut (actif | revoque), cree_par, cree_le.
|
||||
- Octroi : id, coffre_id, secret_id (None = tout le coffre),
|
||||
cible_type (consommateur | utilisateur | executeur | workspace),
|
||||
cible_id, expiration, statut (actif | revoque | expire),
|
||||
cree_par, cree_le, revoque_le.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets as _secrets
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from backend.coffre_crypto import (
|
||||
decrypt_secret_value,
|
||||
encrypt_secret_value,
|
||||
generate_dek,
|
||||
load_kek,
|
||||
parse_kek,
|
||||
reencrypt_secret_value,
|
||||
unwrap_dek,
|
||||
wrap_dek,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.coffre_store")
|
||||
|
||||
_lock = threading.RLock()
|
||||
|
||||
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,63}$")
|
||||
NOM_SECRET_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,127}$")
|
||||
TYPES_SECRET = {"api_key", "token", "password", "certificate", "autre"}
|
||||
PERIMETRES = {"personnel", "partage", "instance"}
|
||||
CIBLES_OCTROI = {"consommateur", "utilisateur", "executeur", "workspace"}
|
||||
|
||||
MAX_VALEUR = 65536
|
||||
MAX_TEXTE = 2000
|
||||
DUREE_OCTROI_DEFAUT_JOURS = 90
|
||||
|
||||
# Motifs refusés dans les champs de métadonnées (description, note) :
|
||||
# une valeur ressemblant à un secret ne doit jamais transiter par un champ
|
||||
# qui, lui, est renvoyé en clair dans les métadonnées.
|
||||
_SECRET_LIKE_PATTERNS = (
|
||||
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
|
||||
re.compile(r"\bsk-[A-Za-z0-9]{8,}\b"),
|
||||
re.compile(r"\bgh[opsu]_[A-Za-z0-9]{8,}\b"),
|
||||
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
||||
re.compile(
|
||||
r"(?i)\b(token|secret|mot_de_passe|password|api[-_]?key)\s*[:=]\s*\S+"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class StoreError(Exception):
|
||||
"""Erreur générique du store."""
|
||||
|
||||
|
||||
class CoffreExisteError(StoreError):
|
||||
pass
|
||||
|
||||
|
||||
class SecretExisteError(StoreError):
|
||||
pass
|
||||
|
||||
|
||||
class IntrouvableError(StoreError):
|
||||
pass
|
||||
|
||||
|
||||
class ValidationError(StoreError):
|
||||
pass
|
||||
|
||||
|
||||
class SecretExpireError(StoreError):
|
||||
pass
|
||||
|
||||
|
||||
def resolve_data_dir(data_dir: str | Path | None = None) -> Path:
|
||||
"""Répertoire de persistance (OBSIGATE_DATA_DIR, défaut ``data``)."""
|
||||
if data_dir is not None:
|
||||
return Path(data_dir)
|
||||
return Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
|
||||
|
||||
|
||||
def _coffres_path(base: Path) -> Path:
|
||||
return base / "coffres.json"
|
||||
|
||||
|
||||
def _valeurs_path(base: Path) -> Path:
|
||||
return base / "coffres_valeurs.json"
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(UTC).isoformat()
|
||||
|
||||
|
||||
def _parse_date(value: str | None) -> datetime | None:
|
||||
"""Parse une date ISO (datetime) ou une date seule (fin de journée UTC)."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) == 10:
|
||||
text = text + "T23:59:59+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(f"Date invalide : {value!r}") from exc
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed
|
||||
|
||||
|
||||
def est_expire(expiration: str | None, maintenant: datetime | None = None) -> bool:
|
||||
"""True si la date d'expiration est passée (None = jamais)."""
|
||||
parsed = _parse_date(expiration)
|
||||
if parsed is None:
|
||||
return False
|
||||
ref = maintenant or datetime.now(UTC)
|
||||
return parsed <= ref
|
||||
|
||||
|
||||
def _read_json(path: Path, default: dict) -> dict:
|
||||
if not path.exists():
|
||||
return json.loads(json.dumps(default))
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("Store coffre illisible (%s) : %s", path, exc)
|
||||
return json.loads(json.dumps(default))
|
||||
return data if isinstance(data, dict) else json.loads(json.dumps(default))
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict) -> None:
|
||||
"""Écriture atomique tmp+rename, fichier final en 0600."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
os.replace(tmp, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _load_coffres(base: Path) -> dict:
|
||||
return _read_json(
|
||||
_coffres_path(base),
|
||||
{"coffres": [], "consommateurs": [], "octrois": []},
|
||||
)
|
||||
|
||||
|
||||
def _load_valeurs(base: Path) -> dict:
|
||||
return _read_json(_valeurs_path(base), {"versions": {}})
|
||||
|
||||
|
||||
def _save_coffres(base: Path, data: dict) -> None:
|
||||
_write_json(_coffres_path(base), data)
|
||||
|
||||
|
||||
def _save_valeurs(base: Path, data: dict) -> None:
|
||||
_write_json(_valeurs_path(base), data)
|
||||
|
||||
|
||||
def valider_slug(slug: str) -> str:
|
||||
value = (slug or "").strip().lower()
|
||||
if not SLUG_RE.match(value):
|
||||
raise ValidationError(
|
||||
"Slug invalide : 3 à 64 caractères, minuscules, chiffres, - ou _"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def valider_nom_secret(nom: str) -> str:
|
||||
value = (nom or "").strip().lower()
|
||||
if not NOM_SECRET_RE.match(value):
|
||||
raise ValidationError(
|
||||
"Nom de secret invalide : 3 à 128 caractères, minuscules, chiffres, - ou _"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def refuser_valeur_dans_metadonnees(texte: str | None, champ: str) -> None:
|
||||
"""Refuse une valeur ressemblant à un secret dans un champ métadonnée."""
|
||||
if not texte:
|
||||
return
|
||||
for motif in _SECRET_LIKE_PATTERNS:
|
||||
if motif.search(texte):
|
||||
raise ValidationError(
|
||||
f"Le champ {champ} ressemble à une valeur de secret ; "
|
||||
"utilisez le champ valeur du secret, jamais une métadonnée"
|
||||
)
|
||||
|
||||
|
||||
def _valider_texte(texte: str | None, champ: str) -> str:
|
||||
value = (texte or "").strip()
|
||||
if len(value) > MAX_TEXTE:
|
||||
raise ValidationError(f"Le champ {champ} dépasse {MAX_TEXTE} caractères")
|
||||
refuser_valeur_dans_metadonnees(value, champ)
|
||||
return value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coffres
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_coffres(data_dir: str | Path | None = None) -> list:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
return list(_load_coffres(base)["coffres"])
|
||||
|
||||
|
||||
def get_coffre(slug: str, data_dir: str | Path | None = None) -> dict | None:
|
||||
base = resolve_data_dir(data_dir)
|
||||
slug = valider_slug(slug)
|
||||
with _lock:
|
||||
for coffre in _load_coffres(base)["coffres"]:
|
||||
if coffre["slug"] == slug:
|
||||
return coffre
|
||||
return None
|
||||
|
||||
|
||||
def get_coffre_or_404(slug: str, data_dir: str | Path | None = None) -> dict:
|
||||
coffre = get_coffre(slug, data_dir)
|
||||
if coffre is None:
|
||||
raise IntrouvableError(f"Coffre introuvable : {slug}")
|
||||
return coffre
|
||||
|
||||
|
||||
def get_coffre_by_id(coffre_id: str, data_dir: str | Path | None = None) -> dict | None:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
for coffre in _load_coffres(base)["coffres"]:
|
||||
if coffre["id"] == coffre_id:
|
||||
return coffre
|
||||
return None
|
||||
|
||||
|
||||
def get_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict | None:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
for octroi in _load_coffres(base)["octrois"]:
|
||||
if octroi["id"] == octroi_id:
|
||||
return octroi
|
||||
return None
|
||||
|
||||
|
||||
def create_coffre(
|
||||
slug: str,
|
||||
nom: str,
|
||||
proprietaire: str,
|
||||
perimetre: str = "personnel",
|
||||
description: str = "",
|
||||
data_dir: str | Path | None = None,
|
||||
) -> dict:
|
||||
"""Crée un coffre. Sans KEK, la DEK reste None (mode dégradé) : les
|
||||
métadonnées sont créées, mais aucun secret ne pourra être ajouté tant
|
||||
que la KEK n'est pas configurée."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
slug = valider_slug(slug)
|
||||
if perimetre not in PERIMETRES:
|
||||
raise ValidationError(f"Périmètre invalide : {perimetre}")
|
||||
if not (proprietaire or "").strip():
|
||||
raise ValidationError("Propriétaire requis")
|
||||
description = _valider_texte(description, "description")
|
||||
nom = (nom or "").strip()
|
||||
if not nom or len(nom) > 128:
|
||||
raise ValidationError("Nom de coffre requis (128 caractères max)")
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
if any(c["slug"] == slug for c in data["coffres"]):
|
||||
raise CoffreExisteError(f"Coffre déjà existant : {slug}")
|
||||
coffre: dict[str, Any] = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"slug": slug,
|
||||
"nom": nom,
|
||||
"description": description,
|
||||
"proprietaire": proprietaire.strip(),
|
||||
"perimetre": perimetre,
|
||||
"dek_chiffree": None,
|
||||
"statut": "actif",
|
||||
"cree_le": _now_iso(),
|
||||
}
|
||||
try:
|
||||
kek = load_kek()
|
||||
except Exception:
|
||||
kek = None
|
||||
if kek is not None:
|
||||
coffre["dek_chiffree"] = wrap_dek(generate_dek(), kek, coffre["id"])
|
||||
data["coffres"].append(coffre)
|
||||
_save_coffres(base, data)
|
||||
return coffre
|
||||
|
||||
|
||||
def ensure_dek(coffre: dict, data_dir: str | Path | None = None) -> bytes:
|
||||
"""Retourne la DEK en clair (mémoire seulement). Génère et persiste la
|
||||
DEK si le coffre a été créé en mode dégradé et que la KEK est là."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
kek = load_kek() # lève KEKAbsenteError si aucune KEK
|
||||
if coffre.get("dek_chiffree"):
|
||||
return unwrap_dek(coffre["dek_chiffree"], kek, coffre["id"])
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
for item in data["coffres"]:
|
||||
if item["id"] == coffre["id"]:
|
||||
if item.get("dek_chiffree"):
|
||||
return unwrap_dek(item["dek_chiffree"], kek, item["id"])
|
||||
dek = generate_dek()
|
||||
item["dek_chiffree"] = wrap_dek(dek, kek, item["id"])
|
||||
_save_coffres(base, data)
|
||||
return dek
|
||||
raise IntrouvableError("Coffre introuvable pour génération de DEK")
|
||||
|
||||
|
||||
# Les secrets vivent dans coffres.json (métadonnées), leurs versions dans
|
||||
# coffres_valeurs.json (chiffrées). Les deux chargements sont faits ici
|
||||
# pour garder une seule source de vérité dans ce module.
|
||||
def _load_tout(base: Path) -> tuple[dict, dict]:
|
||||
data = _load_coffres(base)
|
||||
if "secrets" not in data:
|
||||
data["secrets"] = []
|
||||
return data, _load_valeurs(base)
|
||||
|
||||
|
||||
def compter_secrets(coffre_id: str, data_dir: str | Path | None = None) -> int:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
data, _ = _load_tout(base)
|
||||
return sum(1 for s in data["secrets"] if s["coffre_id"] == coffre_id)
|
||||
|
||||
|
||||
def list_secrets(coffre_id: str, data_dir: str | Path | None = None) -> list:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
data, _ = _load_tout(base)
|
||||
return [s for s in data["secrets"] if s["coffre_id"] == coffre_id]
|
||||
|
||||
|
||||
def get_secret(
|
||||
coffre_id: str, nom: str, data_dir: str | Path | None = None
|
||||
) -> dict | None:
|
||||
base = resolve_data_dir(data_dir)
|
||||
nom = valider_nom_secret(nom)
|
||||
with _lock:
|
||||
data, _ = _load_tout(base)
|
||||
for secret in data["secrets"]:
|
||||
if secret["coffre_id"] == coffre_id and secret["nom"] == nom:
|
||||
return secret
|
||||
return None
|
||||
|
||||
|
||||
def secret_metadata(secret: dict, octrois_actifs: int = 0) -> dict:
|
||||
"""Vue métadonnées seules d'un secret : jamais de valeur, jamais de
|
||||
texte chiffré, jamais de nonce."""
|
||||
return {
|
||||
"id": secret["id"],
|
||||
"nom": secret["nom"],
|
||||
"type": secret["type"],
|
||||
"service": secret.get("service", ""),
|
||||
"note": secret.get("note", ""),
|
||||
"expiration": secret.get("expiration"),
|
||||
"expire": est_expire(secret.get("expiration")),
|
||||
"version": secret["version_courante"],
|
||||
"has_value": True,
|
||||
"cree_le": secret["cree_le"],
|
||||
"modifie_le": secret["modifie_le"],
|
||||
"derniere_utilisation": secret.get("derniere_utilisation"),
|
||||
"octrois_actifs": octrois_actifs,
|
||||
"reference": f"coffre:{secret['coffre_slug']}/{secret['nom']}",
|
||||
}
|
||||
|
||||
|
||||
def create_secret(
|
||||
coffre: dict,
|
||||
nom: str,
|
||||
type_secret: str,
|
||||
valeur: str,
|
||||
service: str = "",
|
||||
expiration: str | None = None,
|
||||
note: str = "",
|
||||
data_dir: str | Path | None = None,
|
||||
) -> dict:
|
||||
"""Chiffre et stocke un secret (nouvelle version 1)."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
nom = valider_nom_secret(nom)
|
||||
if type_secret not in TYPES_SECRET:
|
||||
raise ValidationError(f"Type de secret invalide : {type_secret}")
|
||||
if not valeur or len(valeur) > MAX_VALEUR:
|
||||
raise ValidationError("Valeur requise (64 KiB max)")
|
||||
service = (service or "").strip()
|
||||
if len(service) > 128:
|
||||
raise ValidationError("Service trop long (128 caractères max)")
|
||||
note = _valider_texte(note, "note")
|
||||
if expiration is not None:
|
||||
_parse_date(expiration)
|
||||
dek = ensure_dek(coffre, base) # KEKAbsenteError si pas de KEK
|
||||
with _lock:
|
||||
data, valeurs = _load_tout(base)
|
||||
if any(
|
||||
s["coffre_id"] == coffre["id"] and s["nom"] == nom
|
||||
for s in data["secrets"]
|
||||
):
|
||||
raise SecretExisteError(f"Secret déjà existant : {nom}")
|
||||
secret_id = str(uuid.uuid4())
|
||||
chiffre = encrypt_secret_value(dek, secret_id, 1, valeur)
|
||||
secret = {
|
||||
"id": secret_id,
|
||||
"coffre_id": coffre["id"],
|
||||
"coffre_slug": coffre["slug"],
|
||||
"nom": nom,
|
||||
"type": type_secret,
|
||||
"service": service,
|
||||
"note": note,
|
||||
"expiration": expiration,
|
||||
"version_courante": 1,
|
||||
"cree_le": _now_iso(),
|
||||
"modifie_le": _now_iso(),
|
||||
"derniere_utilisation": None,
|
||||
}
|
||||
data["secrets"].append(secret)
|
||||
valeurs["versions"][secret_id] = [
|
||||
{
|
||||
"numero": 1,
|
||||
"nonce": chiffre["nonce"],
|
||||
"ciphertext": chiffre["ciphertext"],
|
||||
"cree_le": secret["cree_le"],
|
||||
"remplacee_le": None,
|
||||
}
|
||||
]
|
||||
_save_coffres(base, data)
|
||||
_save_valeurs(base, valeurs)
|
||||
return secret
|
||||
|
||||
|
||||
def rotate_secret(
|
||||
coffre: dict, nom: str, valeur: str, data_dir: str | Path | None = None
|
||||
) -> dict:
|
||||
"""Rotation : nouvelle version active immédiatement, l'ancienne n'est
|
||||
plus distribuable (seule la version courante est lue à la distribution)."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
nom = valider_nom_secret(nom)
|
||||
if not valeur or len(valeur) > MAX_VALEUR:
|
||||
raise ValidationError("Valeur requise (64 KiB max)")
|
||||
dek = ensure_dek(coffre, base)
|
||||
with _lock:
|
||||
data, valeurs = _load_tout(base)
|
||||
secret = None
|
||||
for item in data["secrets"]:
|
||||
if item["coffre_id"] == coffre["id"] and item["nom"] == nom:
|
||||
secret = item
|
||||
break
|
||||
if secret is None:
|
||||
raise IntrouvableError(f"Secret introuvable : {nom}")
|
||||
nouvelle_version = secret["version_courante"] + 1
|
||||
chiffre = encrypt_secret_value(dek, secret["id"], nouvelle_version, valeur)
|
||||
versions = valeurs["versions"].setdefault(secret["id"], [])
|
||||
for version in versions:
|
||||
if version["remplacee_le"] is None:
|
||||
version["remplacee_le"] = _now_iso()
|
||||
versions.append(
|
||||
{
|
||||
"numero": nouvelle_version,
|
||||
"nonce": chiffre["nonce"],
|
||||
"ciphertext": chiffre["ciphertext"],
|
||||
"cree_le": _now_iso(),
|
||||
"remplacee_le": None,
|
||||
}
|
||||
)
|
||||
secret["version_courante"] = nouvelle_version
|
||||
secret["modifie_le"] = _now_iso()
|
||||
_save_coffres(base, data)
|
||||
_save_valeurs(base, valeurs)
|
||||
return secret
|
||||
|
||||
|
||||
def delete_secret(
|
||||
coffre: dict, nom: str, data_dir: str | Path | None = None
|
||||
) -> tuple[dict, list]:
|
||||
"""Supprime un secret, ses versions et ses octrois (cascade)."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
nom = valider_nom_secret(nom)
|
||||
with _lock:
|
||||
data, valeurs = _load_tout(base)
|
||||
secret = None
|
||||
for item in data["secrets"]:
|
||||
if item["coffre_id"] == coffre["id"] and item["nom"] == nom:
|
||||
secret = item
|
||||
break
|
||||
if secret is None:
|
||||
raise IntrouvableError(f"Secret introuvable : {nom}")
|
||||
data["secrets"] = [s for s in data["secrets"] if s["id"] != secret["id"]]
|
||||
valeurs["versions"].pop(secret["id"], None)
|
||||
octrois_supprimes = [
|
||||
o for o in data["octrois"] if o.get("secret_id") == secret["id"]
|
||||
]
|
||||
data["octrois"] = [
|
||||
o for o in data["octrois"] if o.get("secret_id") != secret["id"]
|
||||
]
|
||||
_save_coffres(base, data)
|
||||
_save_valeurs(base, valeurs)
|
||||
return secret, octrois_supprimes
|
||||
|
||||
|
||||
def distribuer_valeur(
|
||||
coffre: dict, secret: dict, data_dir: str | Path | None = None
|
||||
) -> str:
|
||||
"""Déchiffre la version courante d'un secret (après contrôles d'octroi
|
||||
faits par le routeur). Refuse un secret expiré (410 côté routeur)."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
if est_expire(secret.get("expiration")):
|
||||
raise SecretExpireError("Secret expiré")
|
||||
dek = ensure_dek(coffre, base)
|
||||
with _lock:
|
||||
_, valeurs = _load_tout(base)
|
||||
versions = valeurs["versions"].get(secret["id"], [])
|
||||
courante = None
|
||||
for version in versions:
|
||||
if version["numero"] == secret["version_courante"]:
|
||||
courante = version
|
||||
break
|
||||
if courante is None:
|
||||
raise IntrouvableError("Version courante introuvable")
|
||||
return decrypt_secret_value(
|
||||
dek,
|
||||
secret["id"],
|
||||
secret["version_courante"],
|
||||
courante["nonce"],
|
||||
courante["ciphertext"],
|
||||
)
|
||||
|
||||
|
||||
def toucher_utilisation(secret_id: str, data_dir: str | Path | None = None) -> None:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
data, _ = _load_tout(base)
|
||||
for secret in data["secrets"]:
|
||||
if secret["id"] == secret_id:
|
||||
secret["derniere_utilisation"] = _now_iso()
|
||||
_save_coffres(base, data)
|
||||
return
|
||||
|
||||
|
||||
def rotate_dek(coffre_slug: str, data_dir: str | Path | None = None) -> dict:
|
||||
"""Rotation DEK : re-chiffre toutes les versions du coffre avec une
|
||||
nouvelle DEK, puis remplace la DEK chiffrée du coffre."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
coffre = get_coffre_or_404(coffre_slug, base)
|
||||
kek = load_kek()
|
||||
ancienne_dek = ensure_dek(coffre, base)
|
||||
nouvelle_dek = generate_dek()
|
||||
with _lock:
|
||||
data, valeurs = _load_tout(base)
|
||||
secrets_coffre = [s for s in data["secrets"] if s["coffre_id"] == coffre["id"]]
|
||||
for secret in secrets_coffre:
|
||||
nouvelles_versions = []
|
||||
for version in valeurs["versions"].get(secret["id"], []):
|
||||
nouvelles_versions.append(
|
||||
{
|
||||
**reencrypt_secret_value(
|
||||
ancienne_dek,
|
||||
nouvelle_dek,
|
||||
secret["id"],
|
||||
version["numero"],
|
||||
version["nonce"],
|
||||
version["ciphertext"],
|
||||
),
|
||||
"numero": version["numero"],
|
||||
"cree_le": version["cree_le"],
|
||||
"remplacee_le": version["remplacee_le"],
|
||||
}
|
||||
)
|
||||
valeurs["versions"][secret["id"]] = nouvelles_versions
|
||||
for item in data["coffres"]:
|
||||
if item["id"] == coffre["id"]:
|
||||
item["dek_chiffree"] = wrap_dek(nouvelle_dek, kek, item["id"])
|
||||
coffre = item
|
||||
break
|
||||
_save_coffres(base, data)
|
||||
_save_valeurs(base, valeurs)
|
||||
return coffre
|
||||
|
||||
|
||||
def rotate_kek(nouvelle_kek_b64: str, data_dir: str | Path | None = None) -> int:
|
||||
"""Rotation KEK : re-chiffre uniquement les DEK avec la nouvelle KEK.
|
||||
L'opérateur doit ensuite remplacer OBSIGATE_KEK / OBSIGATE_KEK_FILE
|
||||
par la nouvelle valeur (cette fonction ne touche pas à l'environnement).
|
||||
Retourne le nombre de coffres re-chiffrés."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
ancienne_kek = load_kek()
|
||||
nouvelle_kek = parse_kek(nouvelle_kek_b64)
|
||||
count = 0
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
for coffre in data["coffres"]:
|
||||
if not coffre.get("dek_chiffree"):
|
||||
continue
|
||||
dek = unwrap_dek(coffre["dek_chiffree"], ancienne_kek, coffre["id"])
|
||||
coffre["dek_chiffree"] = wrap_dek(dek, nouvelle_kek, coffre["id"])
|
||||
count += 1
|
||||
_save_coffres(base, data)
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consommateurs (jetons serveur-à-serveur, hachés, révocables par jti)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _hash_jeton(jeton: str) -> str:
|
||||
return hashlib.sha256(jeton.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def list_consommateurs(data_dir: str | Path | None = None) -> list:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
return [
|
||||
{k: v for k, v in c.items() if k != "jeton_hash"}
|
||||
for c in _load_coffres(base)["consommateurs"]
|
||||
]
|
||||
|
||||
|
||||
def create_consommateur(
|
||||
nom: str, cree_par: str, data_dir: str | Path | None = None
|
||||
) -> tuple[dict, str]:
|
||||
"""Crée un consommateur et retourne (métadonnées, jeton à usage unique).
|
||||
Le jeton n'est JAMAIS persisté : seul son hash SHA-256 est stocké."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
nom = (nom or "").strip()
|
||||
if not nom or len(nom) > 128:
|
||||
raise ValidationError("Nom de consommateur requis (128 caractères max)")
|
||||
jeton = "csk_" + _secrets.token_urlsafe(32)
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
consommateur = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"nom": nom,
|
||||
"jeton_hash": _hash_jeton(jeton),
|
||||
"jeton_prefixe": jeton[:12],
|
||||
"statut": "actif",
|
||||
"cree_par": cree_par,
|
||||
"cree_le": _now_iso(),
|
||||
}
|
||||
data["consommateurs"].append(consommateur)
|
||||
_save_coffres(base, data)
|
||||
public = {k: v for k, v in consommateur.items() if k != "jeton_hash"}
|
||||
return public, jeton
|
||||
|
||||
|
||||
def verifier_jeton_consommateur(
|
||||
jeton: str, data_dir: str | Path | None = None
|
||||
) -> dict | None:
|
||||
"""Retourne le consommateur (sans hash) si le jeton correspond, sinon None."""
|
||||
if not jeton:
|
||||
return None
|
||||
base = resolve_data_dir(data_dir)
|
||||
cible = _hash_jeton(jeton)
|
||||
with _lock:
|
||||
for consommateur in _load_coffres(base)["consommateurs"]:
|
||||
if hmac.compare_digest(consommateur.get("jeton_hash", ""), cible):
|
||||
return {
|
||||
k: v for k, v in consommateur.items() if k != "jeton_hash"
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def revoquer_consommateur(
|
||||
consommateur_id: str, data_dir: str | Path | None = None
|
||||
) -> dict:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
for consommateur in data["consommateurs"]:
|
||||
if consommateur["id"] == consommateur_id:
|
||||
consommateur["statut"] = "revoque"
|
||||
_save_coffres(base, data)
|
||||
return {
|
||||
k: v
|
||||
for k, v in consommateur.items()
|
||||
if k != "jeton_hash"
|
||||
}
|
||||
raise IntrouvableError("Consommateur introuvable")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Octrois
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_octrois(coffre_id: str, data_dir: str | Path | None = None) -> list:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
return [
|
||||
o for o in _load_coffres(base)["octrois"] if o["coffre_id"] == coffre_id
|
||||
]
|
||||
|
||||
|
||||
def compter_octrois_actifs(
|
||||
secret_id: str, data_dir: str | Path | None = None
|
||||
) -> int:
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
return sum(
|
||||
1
|
||||
for o in _load_coffres(base)["octrois"]
|
||||
if o.get("secret_id") == secret_id
|
||||
and o["statut"] == "actif"
|
||||
and not est_expire(o.get("expiration"))
|
||||
)
|
||||
|
||||
|
||||
def create_octroi(
|
||||
coffre: dict,
|
||||
cible_type: str,
|
||||
cible_id: str,
|
||||
secret_id: str | None,
|
||||
cree_par: str,
|
||||
expiration: str | None = None,
|
||||
data_dir: str | Path | None = None,
|
||||
) -> dict:
|
||||
base = resolve_data_dir(data_dir)
|
||||
if cible_type not in CIBLES_OCTROI:
|
||||
raise ValidationError(f"Type de cible invalide : {cible_type}")
|
||||
if not (cible_id or "").strip():
|
||||
raise ValidationError("Cible d'octroi requise")
|
||||
if secret_id is not None:
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
if not any(
|
||||
s["id"] == secret_id and s["coffre_id"] == coffre["id"]
|
||||
for s in data["secrets"]
|
||||
):
|
||||
raise IntrouvableError("Secret introuvable dans ce coffre")
|
||||
if expiration is None:
|
||||
expiration = (datetime.now(UTC) + timedelta(days=DUREE_OCTROI_DEFAUT_JOURS)).isoformat()
|
||||
else:
|
||||
_parse_date(expiration)
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
octroi = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"coffre_id": coffre["id"],
|
||||
"secret_id": secret_id,
|
||||
"cible_type": cible_type,
|
||||
"cible_id": cible_id.strip(),
|
||||
"expiration": expiration,
|
||||
"statut": "actif",
|
||||
"cree_par": cree_par,
|
||||
"cree_le": _now_iso(),
|
||||
"revoque_le": None,
|
||||
}
|
||||
data["octrois"].append(octroi)
|
||||
_save_coffres(base, data)
|
||||
return octroi
|
||||
|
||||
|
||||
def revoquer_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict:
|
||||
"""Révocation : statut -> revoque (conservé pour distinguer
|
||||
octroi_revoque de octroi_absent à la distribution)."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
data = _load_coffres(base)
|
||||
for octroi in data["octrois"]:
|
||||
if octroi["id"] == octroi_id:
|
||||
octroi["statut"] = "revoque"
|
||||
octroi["revoque_le"] = _now_iso()
|
||||
_save_coffres(base, data)
|
||||
return octroi
|
||||
raise IntrouvableError("Octroi introuvable")
|
||||
|
||||
|
||||
def statut_octroi_consommateur(
|
||||
consommateur_id: str,
|
||||
coffre_id: str,
|
||||
secret_id: str,
|
||||
data_dir: str | Path | None = None,
|
||||
) -> tuple[str, dict | None]:
|
||||
"""Statut d'accès d'un consommateur à un secret.
|
||||
|
||||
Retourne ("ok" | "revoque" | "expire" | "absent", octroi). Un octroi
|
||||
coffre-wide (secret_id None) couvre tous les secrets du coffre.
|
||||
Priorité : ok > revoque > expire > absent (un octroi révoqué ne doit
|
||||
jamais être masqué par l'absence d'un autre octroi)."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
candidats = [
|
||||
o
|
||||
for o in _load_coffres(base)["octrois"]
|
||||
if o["cible_type"] == "consommateur"
|
||||
and o["cible_id"] == consommateur_id
|
||||
and o["coffre_id"] == coffre_id
|
||||
and (o.get("secret_id") is None or o["secret_id"] == secret_id)
|
||||
]
|
||||
for octroi in candidats:
|
||||
if octroi["statut"] == "actif" and not est_expire(octroi.get("expiration")):
|
||||
return "ok", octroi
|
||||
for octroi in candidats:
|
||||
if octroi["statut"] == "revoque":
|
||||
return "revoque", octroi
|
||||
for octroi in candidats:
|
||||
if octroi["statut"] == "actif" and est_expire(octroi.get("expiration")):
|
||||
return "expire", octroi
|
||||
return "absent", None
|
||||
|
||||
|
||||
def secrets_accessibles_utilisateur(
|
||||
username: str, coffre: dict, data_dir: str | Path | None = None
|
||||
) -> set | None:
|
||||
"""Secrets d'un coffre visibles par un utilisateur via octroi.
|
||||
|
||||
Retourne None si un octroi coffre-wide actif existe (tout le coffre),
|
||||
sinon l'ensemble des ids de secrets octroyés, sinon l'ensemble vide."""
|
||||
base = resolve_data_dir(data_dir)
|
||||
with _lock:
|
||||
octrois = [
|
||||
o
|
||||
for o in _load_coffres(base)["octrois"]
|
||||
if o["cible_type"] == "utilisateur"
|
||||
and o["cible_id"] == username
|
||||
and o["coffre_id"] == coffre["id"]
|
||||
and o["statut"] == "actif"
|
||||
and not est_expire(o.get("expiration"))
|
||||
]
|
||||
if any(o.get("secret_id") is None for o in octrois):
|
||||
return None
|
||||
return {o["secret_id"] for o in octrois if o.get("secret_id")}
|
||||
@@ -0,0 +1,197 @@
|
||||
"""URLs publiques de l'écosystème dracodev — par configuration (#223).
|
||||
|
||||
Règle dev/prod (Bruno, 2026-10-25) : **aucun nom public de
|
||||
l'écosystème n'est figé dans le code vivant**. Chaque URL publique
|
||||
vient de l'environnement ; les valeurs par défaut sont les noms
|
||||
**officiels** (sans suffixe) de la future production. L'environnement
|
||||
dev déclare ses noms en ``-dev`` dans son seul ``.env`` — le code
|
||||
n'en connaît aucun.
|
||||
|
||||
Variables (voir ``.env.example``) :
|
||||
|
||||
- ``DRACO_AGENT_URL`` — base publique du service d'agents ;
|
||||
à défaut, l'origine est **dérivée** de ``AGENT_EVENEMENTS_URL``
|
||||
(la configuration réelle de l'émission d'événements, #220), puis
|
||||
le défaut officiel.
|
||||
- ``DRACO_OBSIGATE_URL`` — base publique d'ObsiGate (liens profonds).
|
||||
- ``DRACO_FLOWDECK_URL`` — base publique de Flowdeck.
|
||||
- ``DRACO_TIKTAK_URL`` — base publique de TikTak.
|
||||
- ``DRACO_KANBANTE_URL`` — base publique de Kanbante.
|
||||
- ``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
|
||||
l'assistant, réglable depuis l'UI, pas une URL d'affichage).
|
||||
|
||||
Résolution d'une URL sœur (BUG-120) : la variable explicite prime ;
|
||||
à défaut, l'**environnement est dérivé de l'hôte de l'agent** tel
|
||||
qu'ObsiGate le connaît déjà (:func:`agent_url`) — si cet hôte est de
|
||||
la forme ``agent<SUFFIXE>.dracodev.net`` (``-dev`` en dev, vide en
|
||||
prod), l'URL sœur est ``https://<slug><SUFFIXE>.dracodev.net``. Un
|
||||
hôte hors motif (autre domaine, LAN, IP, port) ne dérive **rien** :
|
||||
on ne fabrique jamais un lien deviné, le défaut officiel s'applique.
|
||||
L'installation pointe donc les bons liens selon son environnement
|
||||
sans déclarer les quatre ``DRACO_<APP>_URL``.
|
||||
|
||||
Ce module ne lit l'environnement qu'à l'appel (jamais à l'import) :
|
||||
les tests et un changement de ``.env`` suivi d'un redémarrage
|
||||
prennent effet sans état figé.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Défauts officiels (production future) — jamais de nom en ``-dev`` ici.
|
||||
DEFAUTS: dict[str, str] = {
|
||||
"agent": "https://agent.dracodev.net",
|
||||
"obsigate": "https://og.dracodev.net",
|
||||
"flowdeck": "https://flowdeck.dracodev.net",
|
||||
"tiktak": "https://tiktak.dracodev.net",
|
||||
"kanbante": "https://kanbante.dracodev.net",
|
||||
"gitea": "https://git.dracodev.net",
|
||||
}
|
||||
|
||||
_VARIABLES: dict[str, str] = {
|
||||
"agent": "DRACO_AGENT_URL",
|
||||
"obsigate": "DRACO_OBSIGATE_URL",
|
||||
"flowdeck": "DRACO_FLOWDECK_URL",
|
||||
"tiktak": "DRACO_TIKTAK_URL",
|
||||
"kanbante": "DRACO_KANBANTE_URL",
|
||||
"gitea": "DRACO_GITEA_URL",
|
||||
}
|
||||
|
||||
# Slugs d'hôte des applications sœurs pour la dérivation
|
||||
# d'environnement (BUG-120) : premier label de ``<slug>.dracodev.net``
|
||||
# (et de ``<slug>-dev.dracodev.net`` en dev). Gitea est **exclue** :
|
||||
# son URL ne se dérive jamais.
|
||||
_SLUGS_HOTE: dict[str, str] = {
|
||||
"obsigate": "og",
|
||||
"flowdeck": "flowdeck",
|
||||
"tiktak": "tiktak",
|
||||
"kanbante": "kanbante",
|
||||
}
|
||||
|
||||
# Premier label de l'hôte de l'agent : ``agent`` suivi d'un suffixe
|
||||
# d'environnement éventuel (``-dev`` en dev, vide en prod).
|
||||
_MOTIF_AGENT = re.compile(r"^agent(-[a-z0-9-]*)?$")
|
||||
|
||||
|
||||
def _nettoyer(valeur: str) -> str:
|
||||
return valeur.strip().rstrip("/")
|
||||
|
||||
|
||||
def origine(url: str) -> str:
|
||||
"""Origine ``schéma://hôte[:port]`` d'une URL (``""`` si illisible)."""
|
||||
u = urlparse(url or "")
|
||||
if u.scheme and u.netloc:
|
||||
return f"{u.scheme}://{u.netloc}"
|
||||
return ""
|
||||
|
||||
|
||||
def agent_url() -> str:
|
||||
"""Base publique du service d'agents.
|
||||
|
||||
Priorité : ``DRACO_AGENT_URL``, puis l'origine de
|
||||
``AGENT_EVENEMENTS_URL`` (déjà la configuration réelle du poste),
|
||||
puis le défaut officiel.
|
||||
"""
|
||||
explicite = _nettoyer(os.environ.get(_VARIABLES["agent"], ""))
|
||||
if explicite:
|
||||
return explicite
|
||||
evenements = _nettoyer(os.environ.get("AGENT_EVENEMENTS_URL", ""))
|
||||
if evenements:
|
||||
derivee = origine(evenements)
|
||||
if derivee:
|
||||
return derivee
|
||||
return DEFAUTS["agent"]
|
||||
|
||||
|
||||
def agent_origine() -> str:
|
||||
"""Origine du service d'agents, pour la CSP (``connect-src``/``frame-src``)."""
|
||||
return origine(agent_url()) or DEFAUTS["agent"]
|
||||
|
||||
|
||||
def _suffixe_environnement() -> str | None:
|
||||
"""Suffixe d'environnement déduit de l'hôte de l'agent (BUG-120).
|
||||
|
||||
Lit l'hôte de :func:`agent_url` — l'origine de l'agent telle
|
||||
qu'ObsiGate la connaît déjà (``DRACO_AGENT_URL``, à défaut
|
||||
``AGENT_EVENEMENTS_URL``). Si cet hôte est exactement de la forme
|
||||
``agent<SUFFIXE>.dracodev.net`` sans port, retourne ``SUFFIXE``
|
||||
(``-dev`` en dev, ``""`` en prod). Sinon (autre domaine, LAN, IP,
|
||||
port exotique) retourne ``None`` : aucune dérivation, on ne
|
||||
fabrique jamais un lien deviné.
|
||||
"""
|
||||
u = urlparse(agent_url())
|
||||
hote = (u.hostname or "").lower()
|
||||
labels = hote.split(".")
|
||||
if len(labels) != 3 or labels[1:] != ["dracodev", "net"]:
|
||||
return None
|
||||
try:
|
||||
if u.port is not None:
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
motif = _MOTIF_AGENT.match(labels[0])
|
||||
if motif is None:
|
||||
return None
|
||||
return motif.group(1) or ""
|
||||
|
||||
|
||||
def app_url(app: str) -> str:
|
||||
"""Base publique d'une application de l'écosystème (sans ``/`` final).
|
||||
|
||||
Résolution d'une URL sœur (BUG-120) : ``DRACO_<APP>_URL``
|
||||
explicite d'abord ; à défaut, dérivation d'environnement depuis
|
||||
l'hôte de l'agent (:func:`_suffixe_environnement`) ; à défaut,
|
||||
le défaut officiel. L'URL Gitea ne se dérive jamais.
|
||||
"""
|
||||
if app == "agent":
|
||||
return agent_url()
|
||||
variable = _VARIABLES[app]
|
||||
explicite = _nettoyer(os.environ.get(variable, ""))
|
||||
if explicite:
|
||||
return explicite
|
||||
if app in _SLUGS_HOTE:
|
||||
suffixe = _suffixe_environnement()
|
||||
if suffixe is not None:
|
||||
return f"https://{_SLUGS_HOTE[app]}{suffixe}.dracodev.net"
|
||||
return DEFAUTS[app]
|
||||
|
||||
|
||||
def app_hote(app: str) -> str:
|
||||
"""Nom d'hôte (minuscules) de l'URL publique configurée de *app*."""
|
||||
return (urlparse(app_url(app)).hostname or "").lower()
|
||||
|
||||
|
||||
def hotes_reconnus() -> dict[str, str]:
|
||||
"""``{hôte: app}`` pour le parseur de liens profonds.
|
||||
|
||||
Contient **toujours** les hôtes officiels (un lien collé depuis la
|
||||
production reste reconnaissable en dev, et réciproquement) **plus**
|
||||
l'hôte résolu de chaque application (les noms en ``-dev`` du poste,
|
||||
déclarés dans son ``.env`` ou dérivés de l'hôte de l'agent).
|
||||
"""
|
||||
hotes: dict[str, str] = {}
|
||||
for app, defaut in DEFAUTS.items():
|
||||
hote = (urlparse(defaut).hostname or "").lower()
|
||||
if hote:
|
||||
hotes[hote] = app
|
||||
for app in DEFAUTS:
|
||||
hote = app_hote(app)
|
||||
if hote:
|
||||
hotes[hote] = app
|
||||
return hotes
|
||||
|
||||
|
||||
def urls_publiques() -> dict[str, str]:
|
||||
"""Carte des URL publiques injectée à l'hôte web (``window.DRACO_URLS``).
|
||||
|
||||
C'est le contrat d'injection de dracodev-commons : l'application
|
||||
hôte (ici ObsiGate, qui sert ``index.html``) fournit les URL aux
|
||||
assets partagés (panneau Spark, barre, liens) — les assets ne
|
||||
portent que les défauts officiels en repli.
|
||||
"""
|
||||
return {app: app_url(app) for app in DEFAUTS}
|
||||
@@ -175,6 +175,46 @@ def clear_cookie(response) -> None:
|
||||
response.delete_cookie(COOKIE_NAME, path="/", **extra)
|
||||
|
||||
|
||||
# ── Validation locale (ACC-3.2, #9) ─────────────────────────────────────
|
||||
|
||||
def user_for_draco(request) -> dict | None:
|
||||
"""Utilisateur ObsiGate prouvé par le cookie draco_session.
|
||||
|
||||
ObsiGate est à la fois émettrice et consommatrice : le `sub` du
|
||||
jeton EST son identifiant d'utilisateur — la correspondance est
|
||||
directe, par id. Le garde-fou « jeton antérieur au dernier
|
||||
changement de mot de passe » (BUG-028 côté natif) est appliqué
|
||||
à l'identique via le claim `iat`.
|
||||
"""
|
||||
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
|
||||
if not claims:
|
||||
return None
|
||||
sub = str(claims.get("sub", ""))
|
||||
if not sub:
|
||||
return None
|
||||
from backend.auth.user_store import get_all_users
|
||||
|
||||
for candidate in get_all_users():
|
||||
if str(candidate.get("id")) != sub:
|
||||
continue
|
||||
if not candidate.get("active"):
|
||||
return None
|
||||
pca = candidate.get("password_changed_at")
|
||||
iat = claims.get("iat")
|
||||
if pca is not None and iat is not None:
|
||||
try:
|
||||
if int(iat) < int(float(pca)):
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
user = dict(candidate)
|
||||
user["_token_vaults"] = candidate.get("vaults", [])
|
||||
user["_token_scope"] = "full"
|
||||
user["_draco_sub"] = sub
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
# ── Renouvellement glissant (middleware) ────────────────────────────────
|
||||
|
||||
class DracoRenewalMiddleware:
|
||||
|
||||
+2
-2
@@ -359,7 +359,7 @@ def export_epub(vault_path: Path, file_path: Path) -> bytes:
|
||||
|
||||
rendered = _render_body(body, path.parent, vault_path, path)
|
||||
|
||||
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())}"
|
||||
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.UTC).timestamp())}"
|
||||
xhtml = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
|
||||
@@ -383,7 +383,7 @@ def export_epub(vault_path: Path, file_path: Path) -> bytes:
|
||||
<dc:title>{html_mod.escape(title)}</dc:title>
|
||||
<dc:creator>{html_mod.escape(author)}</dc:creator>
|
||||
<dc:language>fr</dc:language>
|
||||
<meta property="dcterms:modified">{datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
|
||||
<meta property="dcterms:modified">{datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||
|
||||
+76
-10
@@ -24,7 +24,7 @@ import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||
|
||||
logger = logging.getLogger("obsigate.external_refs")
|
||||
|
||||
@@ -64,26 +64,87 @@ def _write_refs(username: str, refs: list[dict[str, Any]]) -> None:
|
||||
|
||||
# ------------------------------------------------------------ parseur d'URL
|
||||
|
||||
def build_deep_link(target_app: str, target_type: str, target_id: str) -> str | None:
|
||||
"""Construit l'URL profonde d'affichage depuis la **configuration**
|
||||
courante (#223), ou ``None`` si la forme est inconnue.
|
||||
|
||||
Les références stockées portent une URL absolue figée au moment du
|
||||
collage (voir ``add_ref``) : elle peut nommer l'autre environnement
|
||||
(un lien collé en prod, lu en dev). L'affichage ne l'utilise donc
|
||||
jamais telle quelle — il reconstruit l'URL depuis
|
||||
``(target_app, target_type, target_id)`` et les bases publiques
|
||||
configurées (``backend.draco_config``), au moment de la lecture.
|
||||
Aucune donnée stockée n'est migrée ni réécrite.
|
||||
"""
|
||||
from backend.draco_config import app_url
|
||||
|
||||
if target_app == "tiktak":
|
||||
if target_type == "task":
|
||||
return f"{app_url('tiktak')}/?task={quote(target_id, safe='')}"
|
||||
if target_type == "list":
|
||||
return f"{app_url('tiktak')}/?list={quote(target_id, safe='')}"
|
||||
return None
|
||||
if target_app == "obsigate" and target_type == "note":
|
||||
return f"{app_url('obsigate')}/#file={quote(target_id, safe=':')}"
|
||||
if target_app == "flowdeck":
|
||||
if target_type == "page":
|
||||
return f"{app_url('flowdeck')}/pages/{quote(target_id, safe='')}"
|
||||
if target_type == "project":
|
||||
return f"{app_url('flowdeck')}/collections/{quote(target_id, safe='')}"
|
||||
return None
|
||||
if target_app == "kanbante" and target_type == "card":
|
||||
m = re.fullmatch(r"([^/]+)/([^/#]+)#(\d+)", target_id or "")
|
||||
if m:
|
||||
owner, repo, num = m.groups()
|
||||
return f"{app_url('gitea')}/{owner}/{repo}/issues/{num}"
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _avec_url_affichee(ref: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Copie de la référence dont ``target_url`` est reconstruite
|
||||
depuis la config courante (#223) — le stockage n'est pas touché."""
|
||||
construite = build_deep_link(
|
||||
str(ref.get("target_app") or ""),
|
||||
str(ref.get("target_type") or ""),
|
||||
str(ref.get("target_id") or ""),
|
||||
)
|
||||
if construite is None:
|
||||
return ref
|
||||
copie = dict(ref)
|
||||
copie["target_url"] = construite
|
||||
return copie
|
||||
|
||||
|
||||
def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
||||
"""Reconnaît une URL profonde des applications dracodev.
|
||||
|
||||
Formes figées par ``dracodev-commons/contrats/liens-profonds.md``.
|
||||
Retourne ``(target_app, target_type, target_id, label_par_défaut)``
|
||||
ou ``None`` si l'URL ne correspond à aucune forme du contrat.
|
||||
|
||||
Hôtes reconnus (#223) : les hôtes **officiels** et les hôtes
|
||||
**configurés** de chaque application (``backend.draco_config``) —
|
||||
un lien collé depuis l'environnement courant (noms en ``-dev``
|
||||
déclarés dans son ``.env``) comme depuis l'autre environnement
|
||||
est donc reconnu ; aucun nom d'environnement n'est figé ici.
|
||||
"""
|
||||
from backend.draco_config import hotes_reconnus
|
||||
|
||||
try:
|
||||
u = urlparse((url or "").strip())
|
||||
except ValueError:
|
||||
return None
|
||||
host = (u.hostname or "").lower()
|
||||
if host == "tiktak.dracodev.net":
|
||||
app = hotes_reconnus().get(host)
|
||||
if app == "tiktak":
|
||||
q = parse_qs(u.query)
|
||||
if q.get("task"):
|
||||
return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}")
|
||||
if q.get("list"):
|
||||
return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}")
|
||||
return None
|
||||
if host == "og.dracodev.net":
|
||||
if app == "obsigate":
|
||||
frag = u.fragment or ""
|
||||
if frag.startswith("file="):
|
||||
cible = unquote(frag[5:])
|
||||
@@ -91,7 +152,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
||||
nom = cible.rsplit("/", 1)[-1]
|
||||
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
|
||||
return None
|
||||
if host == "flowdeck.dracodev.net":
|
||||
if app == "flowdeck":
|
||||
m = re.fullmatch(r"/pages/(\d+)", u.path or "")
|
||||
if m:
|
||||
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
|
||||
@@ -99,7 +160,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
||||
if m:
|
||||
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
|
||||
return None
|
||||
if host == "git.dracodev.net":
|
||||
if app == "gitea":
|
||||
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
|
||||
if m:
|
||||
owner, repo, num = m.groups()
|
||||
@@ -111,20 +172,25 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
||||
# ------------------------------------------------------------ CRUD
|
||||
|
||||
def list_refs(username: str, vault: str, path: str) -> list[dict[str, Any]]:
|
||||
"""Références posées sur une note, dans l'ordre de création."""
|
||||
"""Références posées sur une note, dans l'ordre de création.
|
||||
|
||||
``target_url`` de chaque référence retournée est **reconstruite
|
||||
depuis la config courante** (#223, ``_avec_url_affichee``) : la
|
||||
valeur stockée, figée au collage, n'est jamais servie telle quelle.
|
||||
"""
|
||||
if not username:
|
||||
return []
|
||||
return [
|
||||
r for r in _read_refs(username)
|
||||
_avec_url_affichee(r) for r in _read_refs(username)
|
||||
if r.get("owner_vault") == vault and r.get("owner_path") == path
|
||||
]
|
||||
|
||||
|
||||
def get_ref(username: str, ref_id: str) -> dict[str, Any] | None:
|
||||
"""Une référence par son identifiant, ou ``None``."""
|
||||
"""Une référence par son identifiant, ou ``None`` (URL reconstruite, #223)."""
|
||||
for r in _read_refs(username):
|
||||
if r.get("id") == ref_id:
|
||||
return r
|
||||
return _avec_url_affichee(r)
|
||||
return None
|
||||
|
||||
|
||||
@@ -169,4 +235,4 @@ def delete_ref(username: str, ref_id: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
removed = next(r for r in refs if r.get("id") == ref_id)
|
||||
_write_refs(username, kept)
|
||||
return removed
|
||||
return _avec_url_affichee(removed)
|
||||
|
||||
@@ -355,7 +355,7 @@ def _export_footer(lang: str) -> str:
|
||||
template = loc.get("guide105.export_footer", "")
|
||||
if "%s" not in template and "{" not in template:
|
||||
template = "ObsiGate {version}"
|
||||
today = datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat()
|
||||
today = datetime.datetime.now(tz=datetime.UTC).date().isoformat()
|
||||
return _collapse(template).format(version=_app_version(), date=today)
|
||||
|
||||
|
||||
|
||||
+4
-4
@@ -5,7 +5,7 @@ import os
|
||||
import re
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -581,7 +581,7 @@ def _scan_vault(
|
||||
try:
|
||||
relative = fpath.relative_to(vault_root)
|
||||
stat = fpath.stat()
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
|
||||
|
||||
# #86 differential scan: reuse the previous entry when neither
|
||||
# size nor mtime changed — skips the disk read + parse below.
|
||||
@@ -866,7 +866,7 @@ async def build_index(progress_callback=None) -> None:
|
||||
|
||||
# Record timestamp of full index rebuild
|
||||
global _last_full_index_ts
|
||||
_last_full_index_ts = datetime.now(timezone.utc).isoformat()
|
||||
_last_full_index_ts = datetime.now(UTC).isoformat()
|
||||
|
||||
# Build attachment index
|
||||
from backend.attachment_indexer import build_attachment_index
|
||||
@@ -1033,7 +1033,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
||||
return None
|
||||
|
||||
stat = fpath.stat()
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
|
||||
modified = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
|
||||
|
||||
# PDF handling — binary, must go through pdf_reader (same as _scan_vault)
|
||||
tags: list[str] = []
|
||||
|
||||
+125
-3
@@ -186,15 +186,31 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
# avec un nonce frais (`backend.csp`) ou servis par 'self'/CDN
|
||||
# listés s'exécutent. `style-src` garde 'unsafe-inline' (attributs
|
||||
# `style=` et `el.style` omniprésents — chantier séparé).
|
||||
# #223 : l'origine du service d'agents n'est pas figée — elle
|
||||
# est dérivée de la configuration (DRACO_AGENT_URL, sinon
|
||||
# l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel).
|
||||
from backend.draco_config import agent_origine, origine, urls_publiques
|
||||
|
||||
agent = agent_origine()
|
||||
# Fusion #69 : le panneau Alertes unifiées lit les API
|
||||
# notifications des applications soeurs depuis cet hôte ;
|
||||
# leurs origines rejoignent connect-src (même résolution
|
||||
# draco_config que les liens de la barre : explicite >
|
||||
# dérivation depuis l'hôte de l'agent > défaut officiel).
|
||||
_soeurs69 = " ".join(sorted({
|
||||
origine(v)
|
||||
for k, v in urls_publiques().items()
|
||||
if k in ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||
} - {""}))
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
||||
f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent} {_soeurs69}; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
||||
"worker-src 'self' blob:; "
|
||||
"frame-src 'self' blob:; "
|
||||
f"frame-src 'self' blob: {agent}; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
@@ -515,6 +531,15 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Recherche partout (#68) : CORS bornée pour GET /api/search — ajoutée
|
||||
# après le CORSMiddleware global donc exécutée avant lui (dernier
|
||||
# ajouté = le plus externe) : les preflights OPTIONS sont répondus
|
||||
# ici, et les réponses de /api/search reçoivent les en-têtes sœurs.
|
||||
# La politique same-origin explicite du middleware global reste la
|
||||
# règle par défaut pour tout le reste.
|
||||
from backend.recherche_cors import RechercheCorsMiddleware
|
||||
|
||||
app.add_middleware(RechercheCorsMiddleware)
|
||||
# Auth router
|
||||
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
|
||||
from backend.ai_routes import router as ai_router
|
||||
@@ -524,6 +549,7 @@ from backend.auth.middleware import (
|
||||
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.config import _load_config
|
||||
from backend.routers.config import router as config_router
|
||||
from backend.routers.conflicts import router as conflicts_router
|
||||
@@ -558,6 +584,7 @@ app.include_router(search_router) # ROADMAP #85 T5 — Search
|
||||
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(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
|
||||
@@ -829,6 +856,36 @@ def _html_with_nonce(request: Request, name: str) -> str:
|
||||
request.state.csp_nonce,
|
||||
)
|
||||
|
||||
|
||||
def _index_html(request: Request) -> str:
|
||||
"""``index.html`` + injection des URL publiques de l'écosystème (#223).
|
||||
|
||||
Contrat d'injection de dracodev-commons : l'hôte (ObsiGate, qui
|
||||
sert la page) fournit les URL aux assets partagés — panneau Spark,
|
||||
barre d'apps, liens inter-apps lisent ``window.DRACO_URLS`` et ne
|
||||
portent que les défauts officiels en repli. Les valeurs viennent
|
||||
de :mod:`backend.draco_config` (environnement ; défauts officiels,
|
||||
noms en ``-dev`` déclarés par le seul ``.env`` du poste). Le script
|
||||
injecté passe par le même mécanisme de nonce CSP que les autres
|
||||
scripts inline de la page.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from backend.csp import inject_csp_nonce
|
||||
from backend.draco_config import urls_publiques
|
||||
|
||||
html = (FRONTEND_DIR / "index.html").read_text(encoding="utf-8")
|
||||
script = (
|
||||
"<script>window.DRACO_URLS = "
|
||||
+ _json.dumps(urls_publiques()).replace("<", "\\u003c")
|
||||
+ ";</script>"
|
||||
)
|
||||
if "</head>" in html:
|
||||
html = html.replace("</head>", script + "</head>", 1)
|
||||
else: # repli défensif : tout en tête de document
|
||||
html = script + html
|
||||
return inject_csp_nonce(html, request.state.csp_nonce)
|
||||
|
||||
if FRONTEND_DIR.exists():
|
||||
# ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache).
|
||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||
@@ -902,10 +959,75 @@ if FRONTEND_DIR.exists():
|
||||
return HTMLResponse(content=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Admin page not found")
|
||||
|
||||
@app.get("/recherche", response_class=HTMLResponse)
|
||||
async def serve_recherche_page(request: Request):
|
||||
"""Page « Rechercher partout » (fusion #68) — déclarée AVANT le
|
||||
catch-all SPA comme /admin.html, sinon index.html serait servi.
|
||||
|
||||
frontend/recherche.html est servi avec les jetons {{URL_*}}
|
||||
remplacés par urls_publiques() (même contrat que le fragment
|
||||
de barre) 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 absente) dont le connect-src autorise les
|
||||
origines sœurs — sinon le navigateur bloquerait les appels
|
||||
sœurs avant même CORS. La CSP par défaut (same-origin) reste
|
||||
inchangée pour toutes les autres pages.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from backend.csp import inject_csp_nonce
|
||||
from backend.draco_config import urls_publiques
|
||||
from backend.recherche_cors import origin_of
|
||||
|
||||
recherche_file = FRONTEND_DIR / "recherche.html"
|
||||
if not recherche_file.exists():
|
||||
raise HTTPException(status_code=404, detail="Recherche page not found")
|
||||
html = recherche_file.read_text(encoding="utf-8")
|
||||
urls = urls_publiques()
|
||||
for jeton, cle in (
|
||||
("{{URL_OBSIGATE}}", "obsigate"),
|
||||
("{{URL_FLOWDECK}}", "flowdeck"),
|
||||
("{{URL_TIKTAK}}", "tiktak"),
|
||||
("{{URL_KANBANTE}}", "kanbante"),
|
||||
):
|
||||
html = html.replace(jeton, urls.get(cle, ""))
|
||||
script = (
|
||||
"<script>window.DRACO_URLS = "
|
||||
+ _json.dumps(urls).replace("<", "\\u003c")
|
||||
+ ";</script>"
|
||||
)
|
||||
if "</head>" in html:
|
||||
html = html.replace("</head>", script + "</head>", 1)
|
||||
else:
|
||||
html = script + html
|
||||
html = inject_csp_nonce(html, request.state.csp_nonce)
|
||||
soeurs = " ".join(sorted({
|
||||
origin_of(urls.get(cle, "")) or ""
|
||||
for cle in ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||
} - {""}))
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
f"script-src 'self' 'nonce-{request.state.csp_nonce}'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
f"connect-src 'self' {soeurs}; "
|
||||
"font-src 'self' data:; "
|
||||
"worker-src 'self' blob:; "
|
||||
"frame-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self'; "
|
||||
"form-action 'self'; "
|
||||
"frame-ancestors 'self';"
|
||||
)
|
||||
return HTMLResponse(
|
||||
content=html,
|
||||
headers={"Cache-Control": "no-cache", "Content-Security-Policy": csp},
|
||||
)
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(request: Request, full_path: str):
|
||||
"""Serve the SPA index.html for all non-API routes."""
|
||||
index_file = FRONTEND_DIR / "index.html"
|
||||
if index_file.exists():
|
||||
return HTMLResponse(content=_html_with_nonce(request, "index.html"), headers={"Cache-Control": "no-cache"})
|
||||
return HTMLResponse(content=_index_html(request), headers={"Cache-Control": "no-cache"})
|
||||
raise HTTPException(status_code=404, detail="Frontend not found")
|
||||
|
||||
+4
-4
@@ -25,7 +25,7 @@ import os
|
||||
import smtplib
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -182,7 +182,7 @@ def create_channel(name: str, channel_type: str, config: dict[str, Any]) -> dict
|
||||
"type": channel_type,
|
||||
"enabled": True,
|
||||
"config": normalized,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"last_sent_at": None,
|
||||
"last_error": None,
|
||||
}
|
||||
@@ -287,7 +287,7 @@ def _send_webhook(url: str, title: str, message: str, trigger: str) -> None:
|
||||
"event": trigger,
|
||||
"title": title,
|
||||
"message": message,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"source": "obsigate-notify",
|
||||
},
|
||||
timeout=10.0,
|
||||
@@ -345,6 +345,6 @@ def _mark_sent(channel_id: str, error: str | None) -> None:
|
||||
channels = _read_channels()
|
||||
for channel in channels:
|
||||
if channel.get("id") == channel_id:
|
||||
channel["last_sent_at"] = datetime.now(timezone.utc).isoformat()
|
||||
channel["last_sent_at"] = datetime.now(UTC).isoformat()
|
||||
channel["last_error"] = error
|
||||
_write_channels(channels)
|
||||
|
||||
@@ -41,6 +41,7 @@ TAGS_METADATA: list[dict[str, str]] = [
|
||||
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
|
||||
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
|
||||
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
|
||||
{"name": "Coffres Secrets", "description": "Secret vaults: encrypted coffres, secrets, consumers, grants and distribution — metadata only, values are never exposed."},
|
||||
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
|
||||
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
|
||||
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -185,7 +185,7 @@ async def subscribe_push(
|
||||
"keys": request.subscription.keys,
|
||||
"vault": request.vault,
|
||||
"username": username,
|
||||
"created_at": datetime.now(timezone.utc).isoformat()
|
||||
"created_at": datetime.now(UTC).isoformat()
|
||||
}
|
||||
|
||||
_push_subscriptions.append(subscription)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""CORS bornée pour la recherche fédérée (fusion-trois-apps #68).
|
||||
|
||||
La page « Rechercher partout » (``/recherche``) appelle, depuis le
|
||||
navigateur, ``GET /api/search`` sur chaque application sœur. Sans
|
||||
en-têtes CORS, le navigateur refuse la lecture des réponses
|
||||
cross-origin ; sans borne, on ouvrirait la lecture à tout le web.
|
||||
Ce module n'autorise donc que :
|
||||
|
||||
- le chemin ``/api/search`` (jamais les autres routes, jamais les
|
||||
écritures) ;
|
||||
- les origines sœurs : celles de ``backend.draco_config``
|
||||
(``urls_publiques()`` : explicite > dérivation par l'hôte de
|
||||
l'agent > défaut officiel), **plus** les origines prod ou ``-dev``
|
||||
officielles correspondant à l'hôte de la requête — ce second
|
||||
ensemble couvre le cas où la configuration seule ne déclare pas
|
||||
l'environnement courant ;
|
||||
- avec credentials (le cookie SSO ``draco_session``, accepté par
|
||||
``/api/search`` via le repli de ``backend.auth.middleware``, ou
|
||||
le cookie natif ``access_token``).
|
||||
|
||||
Jamais ``*``. Le middleware est un ASGI pur : il répond lui-même
|
||||
aux preflights OPTIONS (qui ne portent jamais de cookies) et ajoute
|
||||
les en-têtes aux réponses de recherche, sans toucher aux autres
|
||||
routes ni à la politique same-origin explicite du CORSMiddleware
|
||||
global (``allow_origins=[]``), qui reste la règle par défaut.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
SEARCH_PATH = "/api/search"
|
||||
|
||||
PROD_ORIGINS = frozenset({
|
||||
"https://og.dracodev.net",
|
||||
"https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net",
|
||||
"https://kanbante.dracodev.net",
|
||||
})
|
||||
|
||||
DEV_ORIGINS = frozenset({
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
})
|
||||
|
||||
# Clés de draco_config.urls_publiques() qui sont des applications sœurs.
|
||||
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
|
||||
|
||||
|
||||
def is_dev_host(hostname: str | None) -> bool:
|
||||
"""Même règle que la barre v0.1.6 (voir frontend/js/recherche.js)."""
|
||||
if not hostname:
|
||||
return False
|
||||
h = hostname.lower().split(":")[0]
|
||||
if not h.endswith(".dracodev.net"):
|
||||
return False
|
||||
first = h.split(".")[0]
|
||||
return len(first) > 4 and first.endswith("-dev")
|
||||
|
||||
|
||||
def is_prod_host(hostname: str | None) -> bool:
|
||||
"""Hôte dracodev.net qui n'est ni ``-dev`` ni un piège de suffixe."""
|
||||
if not hostname:
|
||||
return False
|
||||
h = hostname.lower().split(":")[0]
|
||||
if not h.endswith(".dracodev.net"):
|
||||
return False
|
||||
first = h.split(".")[0]
|
||||
return len(first) > 0 and not first.endswith("-dev")
|
||||
|
||||
|
||||
def origin_of(url: str | None) -> str | None:
|
||||
"""Origine (schéma + hôte) d'une URL, sans chemin ni ``/`` final."""
|
||||
if not url:
|
||||
return None
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return None
|
||||
host = parsed.hostname.lower()
|
||||
port = f":{parsed.port}" if parsed.port else ""
|
||||
return f"{parsed.scheme}://{host}{port}"
|
||||
|
||||
|
||||
def sister_origins(resolved: dict | None = None, request_host: str | None = None) -> frozenset:
|
||||
"""Origines autorisées pour la lecture cross-origin de /api/search.
|
||||
|
||||
``resolved`` : dictionnaire rendu par ``urls_publiques()``.
|
||||
Sans argument, le résolveur est appelé (import paresseux) ; en cas
|
||||
d'échec, repli sur prod + dev officiels (premier parti uniquement).
|
||||
``request_host`` ajoute l'ensemble officiel de l'environnement de
|
||||
la requête (voir le docstring du module).
|
||||
"""
|
||||
origins = set()
|
||||
if resolved is None:
|
||||
try:
|
||||
from backend.draco_config import urls_publiques
|
||||
|
||||
resolved = urls_publiques()
|
||||
except Exception:
|
||||
resolved = {}
|
||||
for key in _SOEUR_KEYS:
|
||||
origin = origin_of((resolved or {}).get(key))
|
||||
if origin:
|
||||
origins.add(origin)
|
||||
if is_dev_host(request_host):
|
||||
origins |= DEV_ORIGINS
|
||||
elif is_prod_host(request_host):
|
||||
origins |= PROD_ORIGINS
|
||||
if not origins:
|
||||
return PROD_ORIGINS | DEV_ORIGINS
|
||||
return frozenset(origins)
|
||||
|
||||
|
||||
def cors_headers_for(
|
||||
origin: str | None, resolved: dict | None = None, request_host: str | None = None
|
||||
) -> dict | None:
|
||||
"""En-têtes CORS d'une réponse de recherche, ou None si refusée."""
|
||||
if not origin:
|
||||
return None
|
||||
if origin.strip() not in sister_origins(resolved, request_host):
|
||||
return None
|
||||
return {
|
||||
"Access-Control-Allow-Origin": origin.strip(),
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Vary": "Origin",
|
||||
}
|
||||
|
||||
|
||||
def preflight_headers(
|
||||
origin: str | None, resolved: dict | None = None, request_host: str | None = None
|
||||
) -> dict | None:
|
||||
"""En-têtes d'une réponse de preflight OPTIONS pour /api/search."""
|
||||
headers = cors_headers_for(origin, resolved, request_host)
|
||||
if headers is None:
|
||||
return None
|
||||
headers.update({
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Accept, Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "600",
|
||||
})
|
||||
return headers
|
||||
|
||||
|
||||
class RechercheCorsMiddleware:
|
||||
"""ASGI pur : CORS bornée pour ``GET /api/search`` (voir module)."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope.get("type") != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
path = scope.get("path", "")
|
||||
headers_in = {
|
||||
k.decode("latin-1").lower(): v.decode("latin-1")
|
||||
for k, v in scope.get("headers", [])
|
||||
}
|
||||
origin = headers_in.get("origin", "")
|
||||
request_host = (
|
||||
headers_in.get("x-forwarded-host", "") or headers_in.get("host", "")
|
||||
).split(",")[0].strip()
|
||||
if path == SEARCH_PATH and scope.get("method") == "OPTIONS":
|
||||
preflight = preflight_headers(origin, request_host=request_host)
|
||||
if preflight is not None:
|
||||
headers_out = [(k.lower().encode("latin-1"), v.encode("latin-1"))
|
||||
for k, v in preflight.items()]
|
||||
headers_out.append((b"content-length", b"0"))
|
||||
await send({"type": "http.response.start", "status": 204,
|
||||
"headers": headers_out})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
return
|
||||
if path != SEARCH_PATH:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def send_with_cors(message):
|
||||
if message["type"] == "http.response.start":
|
||||
cors = cors_headers_for(origin, request_host=request_host)
|
||||
if cors is not None:
|
||||
message["headers"] = list(message.get("headers", [])) + [
|
||||
(k.lower().encode("latin-1"), v.encode("latin-1"))
|
||||
for k, v in cors.items()
|
||||
]
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_cors)
|
||||
@@ -16,13 +16,16 @@ sortedcontainers>=2.4.0
|
||||
snowballstemmer>=2.2.0
|
||||
weasyprint>=70.0
|
||||
httpx>=0.27.0
|
||||
# Plancher de sécurité (BUG-093) : 6.16.0 est vulnérable à deux DoS de
|
||||
# ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, fix 6.16.1),
|
||||
# atteignables via backend/pdf_reader.py (PDF fournis par l'utilisateur).
|
||||
# Le plancher doit être >= 6.16.1 : l'image Act du runner embarque 6.16.0
|
||||
# dans sa toolcache Python, donc un plancher trop bas est « already satisfied »
|
||||
# et n'est jamais mis à niveau.
|
||||
pypdf>=6.16.1
|
||||
# Plancher de sécurité (BUG-093, relevé par #224) : 6.16.0 est vulnérable à
|
||||
# deux DoS de ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm,
|
||||
# fix 6.16.1), atteignables via backend/pdf_reader.py (PDF fournis par
|
||||
# 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 :
|
||||
# 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
|
||||
pyotp>=2.10.0
|
||||
segno>=1.5.0
|
||||
webauthn==2.6.0
|
||||
@@ -55,3 +58,24 @@ multidict>=6.9.1
|
||||
# transitive (httpx/requests) — sans plancher explicite, pip-audit
|
||||
# signale la version héritée de l'environnement.
|
||||
certifi>=2026.10.19
|
||||
# Planchers de sécurité (#224) : paquets transitifs ou préinstallés dans
|
||||
# la toolcache du runner, audités par pip-audit (CI, bloquant) mais jamais
|
||||
# alignés sans plancher explicite — « already satisfied » (même piège que
|
||||
# BUG-093). Versions relevées par le job security du run sur 2.78.0 :
|
||||
# brotli 1.2.0 (transitive fonttools[woff] via weasyprint) — PYSEC-2026-4322
|
||||
# et GHSA-9v8h-qqgm-4m35, corrigées en 1.2.1.
|
||||
brotli>=1.2.1
|
||||
# filelock 3.32.3 (transitive de CacheControl, outil pip-audit, et
|
||||
# préinstallée dans la toolcache) — CVE-2026-112940, CVE-2026-112913 et
|
||||
# CVE-2026-112907, corrigées en 4.2.1.
|
||||
filelock>=4.2.1
|
||||
# opentelemetry-api 1.37.0 (préinstallée dans la toolcache du runner,
|
||||
# aucune dépendance directe d'ObsiGate) — CVE-2026-114603, corrigée
|
||||
# en 1.45.1.
|
||||
opentelemetry-api>=1.45.1
|
||||
# Coffre Secrets (Phase 1a) : chiffrement par enveloppe AES-256-GCM.
|
||||
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
|
||||
|
||||
@@ -19,7 +19,7 @@ Adaptations strictement équivalentes :
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -207,7 +207,7 @@ async def api_backups_list(
|
||||
"file": rel_file,
|
||||
"backup_file": fpath.name,
|
||||
"timestamp": ts,
|
||||
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
|
||||
"datetime": datetime.fromtimestamp(ts, tz=UTC).isoformat(),
|
||||
"size": fsize,
|
||||
"full_path": str(fpath),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
"""API du coffre Secrets ObsiGate (Phase 1b, spec §7, §10, §11).
|
||||
|
||||
Monté dans ``backend/main.py`` à côté de ``config_router`` — JAMAIS dans
|
||||
``backend/routers/vaults.py`` : un coffre n'est pas un vault de notes, il
|
||||
ne passe ni par l'indexeur, ni par la recherche, ni par le graphe, et
|
||||
aucun outil MCP de secret n'existe.
|
||||
|
||||
Règles d'or, appliquées côté serveur à chaque requête :
|
||||
- Toutes les lectures renvoient des métadonnées seules (``has_value``,
|
||||
version, dates, octrois actifs) : jamais de valeur, jamais de texte
|
||||
chiffré, jamais de nonce.
|
||||
- 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.
|
||||
- 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
|
||||
ne donne AUCUN droit sur un coffre (``check_vault_access`` n'est pas
|
||||
utilisé ici, volontairement).
|
||||
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
|
||||
qu'il n'est pas activé, toutes les routes répondent 503.
|
||||
- Phase 2 (issues #20) : migration ``api_keys.json`` -> coffre
|
||||
d'instance, en deux temps (import, puis purge confirmée), et
|
||||
enregistrement d'un consommateur avec ses octrois en un appel.
|
||||
- Phase 3 (issue #21) : ``/distribuer`` accepte le contexte Spark à
|
||||
plat ou imbriqué (``contexte: {type: "mission", id}``) ; les codes
|
||||
d'erreur restent ceux de la Phase 1b (voir
|
||||
``docs/COFFRE-contrat-distribution.md`` pour la correspondance avec
|
||||
l'état ``acces_requis`` côté Spark).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
|
||||
from backend.audit import get_recent_entries, log_coffre_event
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.coffre_crypto import (
|
||||
CryptoIndisponibleError,
|
||||
KEKAbsenteError,
|
||||
generate_kek_b64,
|
||||
)
|
||||
from backend.coffre_migration import (
|
||||
COFFRE_INTERNE_SLUG,
|
||||
creer_consommateur_avec_octrois,
|
||||
importer_api_keys,
|
||||
lister_candidats,
|
||||
purger_api_keys,
|
||||
)
|
||||
from backend.coffre_store import (
|
||||
CoffreExisteError,
|
||||
IntrouvableError,
|
||||
SecretExisteError,
|
||||
SecretExpireError,
|
||||
ValidationError,
|
||||
compter_octrois_actifs,
|
||||
compter_secrets,
|
||||
create_coffre,
|
||||
create_consommateur,
|
||||
create_octroi,
|
||||
create_secret,
|
||||
delete_secret,
|
||||
distribuer_valeur,
|
||||
get_coffre,
|
||||
get_coffre_by_id,
|
||||
get_coffre_or_404,
|
||||
get_octroi,
|
||||
get_secret,
|
||||
list_coffres,
|
||||
list_consommateurs,
|
||||
list_octrois,
|
||||
list_secrets,
|
||||
revoquer_consommateur,
|
||||
revoquer_octroi,
|
||||
rotate_dek,
|
||||
rotate_kek,
|
||||
rotate_secret,
|
||||
secret_metadata,
|
||||
secrets_accessibles_utilisateur,
|
||||
statut_octroi_consommateur,
|
||||
toucher_utilisation,
|
||||
verifier_jeton_consommateur,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.coffres")
|
||||
|
||||
router = APIRouter(tags=["Coffres Secrets"])
|
||||
|
||||
CODES_ERREUR = {
|
||||
"octroi_absent",
|
||||
"octroi_revoque",
|
||||
"octroi_expire",
|
||||
"secret_expire",
|
||||
"kek_absente",
|
||||
"contexte_manquant",
|
||||
"consommateur_invalide",
|
||||
"consommateur_revoque",
|
||||
"crypto_indisponible",
|
||||
"secrets_desactives",
|
||||
}
|
||||
|
||||
|
||||
def secrets_actives() -> bool:
|
||||
"""Feature flag : défaut désactivé, activation explicite par l'opérateur."""
|
||||
return os.environ.get("OBSIGATE_SECRETS_ENABLED", "false").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
|
||||
|
||||
def _exiger_actives() -> None:
|
||||
if not secrets_actives():
|
||||
raise HTTPException(status_code=503, detail="secrets_desactives")
|
||||
|
||||
|
||||
def _username(user: dict) -> str:
|
||||
return str(user.get("username", ""))
|
||||
|
||||
|
||||
def _est_admin(user: dict) -> bool:
|
||||
return user.get("role") == "admin"
|
||||
|
||||
|
||||
def _peut_voir_coffre(user: dict, coffre: dict) -> bool:
|
||||
"""Voir les métadonnées d'un coffre."""
|
||||
if coffre["proprietaire"] == _username(user):
|
||||
return True
|
||||
if _est_admin(user) and coffre["perimetre"] == "instance":
|
||||
return True
|
||||
# Octroi utilisateur (coffre-wide ou par secret) : accès en lecture.
|
||||
accessibles = secrets_accessibles_utilisateur(_username(user), coffre)
|
||||
if accessibles is None:
|
||||
return True
|
||||
return len(accessibles) > 0
|
||||
|
||||
|
||||
def _peut_gerer_coffre(user: dict, coffre: dict) -> bool:
|
||||
"""Créer / roter / supprimer des secrets et gérer les octrois."""
|
||||
if coffre["proprietaire"] == _username(user):
|
||||
return True
|
||||
return _est_admin(user) and coffre["perimetre"] == "instance"
|
||||
|
||||
|
||||
def _exiger_voir(user: dict, coffre: dict) -> None:
|
||||
if not _peut_voir_coffre(user, coffre):
|
||||
raise HTTPException(status_code=403, detail="Accès refusé à ce coffre")
|
||||
|
||||
|
||||
def _exiger_gerer(user: dict, coffre: dict) -> None:
|
||||
if not _peut_gerer_coffre(user, coffre):
|
||||
raise HTTPException(status_code=403, detail="Accès refusé à ce coffre")
|
||||
|
||||
|
||||
def _coffre_or_404(slug: str) -> dict:
|
||||
try:
|
||||
return get_coffre_or_404(slug)
|
||||
except IntrouvableError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _secret_or_404(coffre: dict, nom: str) -> dict:
|
||||
try:
|
||||
secret = get_secret(coffre["id"], nom)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
if secret is None:
|
||||
raise HTTPException(status_code=404, detail=f"Secret introuvable : {nom}")
|
||||
return secret
|
||||
|
||||
|
||||
def _metadata_coffre(coffre: dict) -> dict:
|
||||
return {
|
||||
"slug": coffre["slug"],
|
||||
"nom": coffre["nom"],
|
||||
"description": coffre.get("description", ""),
|
||||
"proprietaire": coffre["proprietaire"],
|
||||
"perimetre": coffre["perimetre"],
|
||||
"statut": coffre["statut"],
|
||||
"kek_configuree": coffre.get("dek_chiffree") is not None,
|
||||
"nb_secrets": compter_secrets(coffre["id"]),
|
||||
"cree_le": coffre["cree_le"],
|
||||
}
|
||||
|
||||
|
||||
def _metadata_secret(coffre: dict, secret: dict) -> dict:
|
||||
return secret_metadata(secret, compter_octrois_actifs(secret["id"]))
|
||||
|
||||
|
||||
def _secrets_visibles(user: dict, coffre: dict) -> list:
|
||||
"""Secrets visibles par l'utilisateur (métadonnées seules)."""
|
||||
if coffre["proprietaire"] == _username(user) or (
|
||||
_est_admin(user) and coffre["perimetre"] == "instance"
|
||||
):
|
||||
return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])]
|
||||
accessibles = secrets_accessibles_utilisateur(_username(user), coffre)
|
||||
if accessibles is None:
|
||||
return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])]
|
||||
return [
|
||||
_metadata_secret(coffre, s)
|
||||
for s in list_secrets(coffre["id"])
|
||||
if s["id"] in accessibles
|
||||
]
|
||||
|
||||
|
||||
def _audit(user: str, action: str, **kwargs) -> None:
|
||||
try:
|
||||
log_coffre_event(user, action, **kwargs)
|
||||
except Exception as exc: # l'audit ne doit jamais casser l'opération
|
||||
logger.warning("Échec audit coffre (%s) : %s", action, exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coffres
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/coffres")
|
||||
async def api_list_coffres(current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
visibles = [c for c in list_coffres() if _peut_voir_coffre(current_user, c)]
|
||||
return [_metadata_coffre(c) for c in visibles]
|
||||
|
||||
|
||||
@router.post("/api/coffres")
|
||||
async def api_create_coffre(body: dict = Body(...), current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
slug = body.get("slug", "")
|
||||
perimetre = body.get("perimetre", "personnel")
|
||||
if perimetre == "instance" and not _est_admin(current_user):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Seul un admin peut créer un coffre d'instance"
|
||||
)
|
||||
try:
|
||||
coffre = create_coffre(
|
||||
slug=slug,
|
||||
nom=body.get("nom", ""),
|
||||
proprietaire=_username(current_user),
|
||||
perimetre=perimetre,
|
||||
description=body.get("description", ""),
|
||||
)
|
||||
except CoffreExisteError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"coffre_cree",
|
||||
coffre=coffre["slug"],
|
||||
resultat="ok",
|
||||
)
|
||||
return _metadata_coffre(coffre)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consommateurs (admin seulement, jeton rendu une seule fois)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/coffres/consommateurs")
|
||||
async def api_list_consommateurs(current_user=Depends(require_admin)):
|
||||
_exiger_actives()
|
||||
return list_consommateurs()
|
||||
|
||||
|
||||
@router.post("/api/coffres/consommateurs")
|
||||
async def api_create_consommateur(
|
||||
body: dict = Body(...), current_user=Depends(require_admin)
|
||||
):
|
||||
_exiger_actives()
|
||||
try:
|
||||
consommateur, jeton = create_consommateur(
|
||||
nom=body.get("nom", ""), cree_par=_username(current_user)
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"consommateur_cree",
|
||||
consommateur=consommateur["nom"],
|
||||
resultat="ok",
|
||||
)
|
||||
# Le jeton n'est rendu qu'ici, jamais stocké en clair, jamais relisté.
|
||||
return {**consommateur, "jeton": jeton}
|
||||
|
||||
|
||||
@router.post("/api/coffres/consommateurs/avec-octrois")
|
||||
async def api_create_consommateur_avec_octrois(
|
||||
body: dict = Body(...), current_user=Depends(require_admin)
|
||||
):
|
||||
"""Phase 4 : enregistrer une app = un consommateur + ses octrois.
|
||||
|
||||
Le coffre n'est jamais modifié par cette opération. Le jeton n'est
|
||||
rendu qu'ici, jamais stocké en clair, jamais relisté. Un octroi en
|
||||
erreur (coffre ou secret introuvable) est signalé dans ``erreurs``
|
||||
sans annuler le consommateur ni les autres octrois.
|
||||
"""
|
||||
_exiger_actives()
|
||||
try:
|
||||
consommateur, jeton, octrois, erreurs = creer_consommateur_avec_octrois(
|
||||
nom=body.get("nom", ""),
|
||||
octrois_demandes=body.get("octrois") or [],
|
||||
cree_par=_username(current_user),
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"consommateur_cree",
|
||||
consommateur=consommateur["nom"],
|
||||
resultat="ok",
|
||||
)
|
||||
for octroi in octrois:
|
||||
coffre_octroi = get_coffre_by_id(octroi["coffre_id"])
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"octroi_cree",
|
||||
coffre=coffre_octroi["slug"] if coffre_octroi else None,
|
||||
resultat="ok",
|
||||
)
|
||||
return {**consommateur, "jeton": jeton, "octrois": octrois, "erreurs": erreurs}
|
||||
|
||||
|
||||
@router.delete("/api/coffres/consommateurs/{consommateur_id}")
|
||||
async def api_revoke_consommateur(
|
||||
consommateur_id: str, current_user=Depends(require_admin)
|
||||
):
|
||||
_exiger_actives()
|
||||
try:
|
||||
consommateur = revoquer_consommateur(consommateur_id)
|
||||
except IntrouvableError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"consommateur_revoque",
|
||||
consommateur=consommateur["nom"],
|
||||
resultat="ok",
|
||||
)
|
||||
return consommateur
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration Phase 2 (admin seulement) : api_keys.json -> coffre d'instance.
|
||||
# L'import ne supprime rien ; la purge exige une confirmation explicite
|
||||
# et ne supprime que les noms listés. Aucune réponse ne contient jamais
|
||||
# une valeur, seulement des noms et des comptes.
|
||||
# Ces routes sont déclarées AVANT /api/coffres/{slug} pour ne pas être
|
||||
# capturées par le paramètre slug.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/coffres/migration/api-keys")
|
||||
async def api_migration_apercu(current_user=Depends(require_admin)):
|
||||
_exiger_actives()
|
||||
return {"coffre": COFFRE_INTERNE_SLUG, "candidats": lister_candidats()}
|
||||
|
||||
|
||||
@router.post("/api/coffres/migration/api-keys/import")
|
||||
async def api_migration_import(current_user=Depends(require_admin)):
|
||||
_exiger_actives()
|
||||
try:
|
||||
resultat = importer_api_keys(cree_par=_username(current_user))
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except KEKAbsenteError:
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except CryptoIndisponibleError:
|
||||
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
|
||||
for nom in resultat["migres"]:
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"migration_api_keys",
|
||||
coffre=COFFRE_INTERNE_SLUG,
|
||||
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
|
||||
resultat="ok",
|
||||
)
|
||||
return resultat
|
||||
|
||||
|
||||
@router.post("/api/coffres/migration/api-keys/purger")
|
||||
async def api_migration_purger(
|
||||
body: dict = Body(...), current_user=Depends(require_admin)
|
||||
):
|
||||
_exiger_actives()
|
||||
if body.get("confirmer") is not True:
|
||||
raise HTTPException(status_code=400, detail="confirmation_requise")
|
||||
try:
|
||||
resultat = purger_api_keys(
|
||||
noms=body.get("noms") or [], confirmer=True
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
for nom in resultat["supprimes"]:
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"migration_api_keys_purge",
|
||||
coffre=COFFRE_INTERNE_SLUG,
|
||||
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
|
||||
resultat="ok",
|
||||
)
|
||||
return resultat
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Distribution (serveur-à-serveur, hors session utilisateur, hors LLM)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/coffres/distribuer")
|
||||
async def api_distribuer(request: Request, body: dict = Body(...)):
|
||||
_exiger_actives()
|
||||
reference = (body.get("reference") or "").strip()
|
||||
# Phase 3 (Spark) : le contexte peut arriver à plat
|
||||
# (contexte_type / contexte_id) ou imbriqué (contexte: {type, id},
|
||||
# forme envoyée par le client Spark). Les deux formes sont acceptées.
|
||||
contexte_type = str(body.get("contexte_type") or "").strip()
|
||||
contexte_id_raw = body.get("contexte_id")
|
||||
contexte_id = "" if contexte_id_raw is None else str(contexte_id_raw).strip()
|
||||
if not contexte_type or not contexte_id:
|
||||
contexte = body.get("contexte")
|
||||
if isinstance(contexte, dict):
|
||||
contexte_type = contexte_type or str(contexte.get("type") or "").strip()
|
||||
contexte_id_raw = contexte.get("id")
|
||||
contexte_id = contexte_id or (
|
||||
"" if contexte_id_raw is None else str(contexte_id_raw).strip()
|
||||
)
|
||||
if not contexte_type or not contexte_id:
|
||||
raise HTTPException(status_code=400, detail="contexte_manquant")
|
||||
if not reference.startswith("coffre:") or "/" not in reference[7:]:
|
||||
raise HTTPException(status_code=400, detail="reference_invalide")
|
||||
slug, nom = reference[7:].split("/", 1)
|
||||
# Authentification consommateur : Bearer, hash + statut, jamais un JWT
|
||||
# de session utilisateur.
|
||||
auth = request.headers.get("Authorization", "")
|
||||
jeton = auth[7:] if auth.startswith("Bearer ") else ""
|
||||
consommateur = verifier_jeton_consommateur(jeton)
|
||||
if consommateur is None:
|
||||
_audit("consommateur", "distribution_refusee", secret_ref=reference,
|
||||
resultat="consommateur_invalide", contexte_id=contexte_id)
|
||||
raise HTTPException(status_code=401, detail="consommateur_invalide")
|
||||
if consommateur["statut"] != "actif":
|
||||
_audit("consommateur", "distribution_refusee", secret_ref=reference,
|
||||
consommateur=consommateur["nom"],
|
||||
resultat="consommateur_revoque", contexte_id=contexte_id)
|
||||
raise HTTPException(status_code=403, detail="consommateur_revoque")
|
||||
try:
|
||||
coffre = get_coffre(slug)
|
||||
secret = get_secret(coffre["id"], nom) if coffre else None
|
||||
except ValidationError:
|
||||
raise HTTPException(status_code=400, detail="reference_invalide") from None
|
||||
if coffre is None or secret is None or coffre["statut"] != "actif":
|
||||
# Anti-énumération : un secret inexistant se comporte comme un
|
||||
# octroi absent, il ne révèle pas l'existence du coffre/secret.
|
||||
_audit("consommateur", "distribution_refusee", secret_ref=reference,
|
||||
consommateur=consommateur["nom"],
|
||||
resultat="octroi_absent", contexte_id=contexte_id)
|
||||
raise HTTPException(status_code=403, detail="octroi_absent")
|
||||
statut, _octroi = statut_octroi_consommateur(
|
||||
consommateur["id"], coffre["id"], secret["id"]
|
||||
)
|
||||
if statut != "ok":
|
||||
code = {
|
||||
"revoque": "octroi_revoque",
|
||||
"expire": "octroi_expire",
|
||||
"absent": "octroi_absent",
|
||||
}[statut]
|
||||
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
|
||||
secret_ref=reference, consommateur=consommateur["nom"],
|
||||
resultat=code, contexte_id=contexte_id)
|
||||
raise HTTPException(status_code=403, detail=code)
|
||||
try:
|
||||
valeur = distribuer_valeur(coffre, secret)
|
||||
except SecretExpireError:
|
||||
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
|
||||
secret_ref=reference, consommateur=consommateur["nom"],
|
||||
resultat="secret_expire", contexte_id=contexte_id)
|
||||
raise HTTPException(status_code=410, detail="secret_expire") from None
|
||||
except KEKAbsenteError:
|
||||
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
|
||||
secret_ref=reference, consommateur=consommateur["nom"],
|
||||
resultat="kek_absente", contexte_id=contexte_id)
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except CryptoIndisponibleError:
|
||||
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
|
||||
toucher_utilisation(secret["id"])
|
||||
_audit("consommateur", "distribution_ok", coffre=coffre["slug"],
|
||||
secret_ref=reference, consommateur=consommateur["nom"],
|
||||
resultat="ok", contexte_id=contexte_id)
|
||||
return {
|
||||
"reference": reference,
|
||||
"valeur": valeur,
|
||||
"version": secret["version_courante"],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Un coffre, ses secrets, ses octrois, son journal
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/coffres/{slug}")
|
||||
async def api_get_coffre(slug: str, current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_voir(current_user, coffre)
|
||||
return _metadata_coffre(coffre)
|
||||
|
||||
|
||||
@router.get("/api/coffres/{slug}/secrets")
|
||||
async def api_list_secrets(slug: str, current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_voir(current_user, coffre)
|
||||
return _secrets_visibles(current_user, coffre)
|
||||
|
||||
|
||||
@router.post("/api/coffres/{slug}/secrets")
|
||||
async def api_create_secret(
|
||||
slug: str, body: dict = Body(...), current_user=Depends(require_auth)
|
||||
):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_gerer(current_user, coffre)
|
||||
try:
|
||||
secret = create_secret(
|
||||
coffre=coffre,
|
||||
nom=body.get("nom", ""),
|
||||
type_secret=body.get("type", "autre"),
|
||||
valeur=body.get("valeur", ""),
|
||||
service=body.get("service", ""),
|
||||
expiration=body.get("expiration"),
|
||||
note=body.get("note", ""),
|
||||
)
|
||||
except SecretExisteError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except KEKAbsenteError:
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except CryptoIndisponibleError:
|
||||
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"secret_cree",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="ok",
|
||||
)
|
||||
return _metadata_secret(coffre, secret)
|
||||
|
||||
|
||||
@router.post("/api/coffres/{slug}/secrets/{nom}/rotation")
|
||||
async def api_rotate_secret(
|
||||
slug: str, nom: str, body: dict = Body(...), current_user=Depends(require_auth)
|
||||
):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_gerer(current_user, coffre)
|
||||
try:
|
||||
secret = rotate_secret(coffre, nom, body.get("valeur", ""))
|
||||
except IntrouvableError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except KEKAbsenteError:
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except CryptoIndisponibleError:
|
||||
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"secret_rotation",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="ok",
|
||||
)
|
||||
return _metadata_secret(coffre, secret)
|
||||
|
||||
|
||||
@router.delete("/api/coffres/{slug}/secrets/{nom}")
|
||||
async def api_delete_secret(
|
||||
slug: str, nom: str, current_user=Depends(require_auth)
|
||||
):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_gerer(current_user, coffre)
|
||||
try:
|
||||
secret, _octrois = delete_secret(coffre, nom)
|
||||
except IntrouvableError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"secret_supprime",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="ok",
|
||||
)
|
||||
return {"status": "deleted", "nom": secret["nom"]}
|
||||
|
||||
|
||||
@router.get("/api/coffres/{slug}/octrois")
|
||||
async def api_list_octrois(slug: str, current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_voir(current_user, coffre)
|
||||
return list_octrois(coffre["id"])
|
||||
|
||||
|
||||
@router.post("/api/coffres/{slug}/octrois")
|
||||
async def api_create_octroi(
|
||||
slug: str, body: dict = Body(...), current_user=Depends(require_auth)
|
||||
):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_gerer(current_user, coffre)
|
||||
secret_id = None
|
||||
if body.get("secret_nom"):
|
||||
secret = _secret_or_404(coffre, body["secret_nom"])
|
||||
secret_id = secret["id"]
|
||||
try:
|
||||
octroi = create_octroi(
|
||||
coffre=coffre,
|
||||
cible_type=body.get("cible_type", ""),
|
||||
cible_id=body.get("cible_id", ""),
|
||||
secret_id=secret_id,
|
||||
cree_par=_username(current_user),
|
||||
expiration=body.get("expiration"),
|
||||
)
|
||||
except IntrouvableError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"octroi_cree",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=(
|
||||
f"coffre:{coffre['slug']}/{body.get('secret_nom', '*')}"
|
||||
),
|
||||
resultat="ok",
|
||||
)
|
||||
return octroi
|
||||
|
||||
|
||||
@router.delete("/api/coffres/octrois/{octroi_id}")
|
||||
async def api_revoke_octroi(octroi_id: str, current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
# Le gestionnaire du coffre concerné (ou l'admin d'instance) seul peut
|
||||
# révoquer : on résout le coffre depuis l'octroi avant toute action.
|
||||
octroi = get_octroi(octroi_id)
|
||||
if octroi is None:
|
||||
raise HTTPException(status_code=404, detail="Octroi introuvable")
|
||||
coffre = get_coffre_by_id(octroi["coffre_id"])
|
||||
if coffre is None:
|
||||
raise HTTPException(status_code=404, detail="Coffre introuvable")
|
||||
_exiger_gerer(current_user, coffre)
|
||||
try:
|
||||
octroi = revoquer_octroi(octroi_id)
|
||||
except IntrouvableError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
_audit(
|
||||
_username(current_user),
|
||||
"octroi_revoque",
|
||||
coffre=coffre["slug"],
|
||||
resultat="ok",
|
||||
)
|
||||
return octroi
|
||||
|
||||
|
||||
@router.get("/api/coffres/{slug}/journal")
|
||||
async def api_journal(slug: str, current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_voir(current_user, coffre)
|
||||
# Défense en profondeur : même si une entrée contenait un jour un champ
|
||||
# sensible, il ne sort jamais par cette route.
|
||||
nettoyees = []
|
||||
for entry in get_recent_entries(limit=100):
|
||||
if entry.get("coffre") != slug:
|
||||
continue
|
||||
nettoyees.append(
|
||||
{
|
||||
k: v
|
||||
for k, v in entry.items()
|
||||
if k not in ("valeur", "ciphertext", "nonce", "jeton", "dek_chiffree")
|
||||
}
|
||||
)
|
||||
return nettoyees
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Administration KEK / rotations (admin seulement)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/coffres/admin/generer-kek")
|
||||
async def api_generer_kek(current_user=Depends(require_admin)):
|
||||
_exiger_actives()
|
||||
# La KEK générée n'est rendue qu'une fois, ici, et jamais stockée par
|
||||
# ObsiGate : l'opérateur doit la sauvegarder hors dépôt immédiatement.
|
||||
return {"kek": generate_kek_b64()}
|
||||
|
||||
|
||||
@router.post("/api/coffres/{slug}/rotation-dek")
|
||||
async def api_rotate_dek(slug: str, current_user=Depends(require_auth)):
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_gerer(current_user, coffre)
|
||||
try:
|
||||
coffre = rotate_dek(slug)
|
||||
except KEKAbsenteError:
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except CryptoIndisponibleError:
|
||||
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
|
||||
_audit(_username(current_user), "dek_rotation", coffre=coffre["slug"],
|
||||
resultat="ok")
|
||||
return _metadata_coffre(coffre)
|
||||
|
||||
|
||||
@router.post("/api/coffres/admin/rotation-kek")
|
||||
async def api_rotate_kek(
|
||||
body: dict = Body(...), current_user=Depends(require_admin)
|
||||
):
|
||||
_exiger_actives()
|
||||
try:
|
||||
count = rotate_kek(body.get("nouvelle_kek", ""))
|
||||
except KEKAbsenteError:
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
_audit(_username(current_user), "kek_rotation", resultat="ok")
|
||||
return {"status": "ok", "coffres_rechiffres": count}
|
||||
@@ -15,6 +15,7 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend import agent_journal
|
||||
from backend.audit import log_file_delete, log_file_save
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.history import (
|
||||
@@ -133,6 +134,9 @@ async def api_file_save(
|
||||
client_ip = current_user.get("_request_ip", "unknown")
|
||||
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
||||
|
||||
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
|
||||
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||
|
||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||
|
||||
|
||||
@@ -201,6 +205,7 @@ def api_file_xlsx_save(
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
@@ -250,6 +255,7 @@ def api_file_csv_save(
|
||||
sum(len(str(v)) for v in cells.values()),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": result["size"], "revision": result.get("revision"),
|
||||
@@ -313,6 +319,7 @@ def api_file_xlsx_structure(
|
||||
len(actions),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
@@ -381,6 +388,7 @@ def api_file_xlsx_style(
|
||||
len(ops),
|
||||
current_user.get("_request_ip", "unknown"),
|
||||
)
|
||||
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||
return {
|
||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||
@@ -405,6 +413,9 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
|
||||
|
||||
result = service_delete_file(vault_name, path)
|
||||
|
||||
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
|
||||
agent_journal.emettre(agent_journal.TYPE_SUPPRIME, vault_name, result["path"])
|
||||
|
||||
# Audit log
|
||||
client_ip = current_user.get("_request_ip", "unknown")
|
||||
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
||||
@@ -577,6 +588,9 @@ async def api_file_create(
|
||||
|
||||
result = service_create_file(vault_name, body.path, body.content)
|
||||
|
||||
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
|
||||
agent_journal.emettre(agent_journal.TYPE_CREE, vault_name, result["path"])
|
||||
|
||||
# Update index
|
||||
await update_single_file(vault_name, result["path"])
|
||||
|
||||
@@ -686,6 +700,9 @@ async def api_file_rename(
|
||||
old_path_str = result["old_path"]
|
||||
new_path_str = result["new_path"]
|
||||
|
||||
# Journal du service d'agents (#220) — la référence pointe sur le nouveau chemin.
|
||||
agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str)
|
||||
|
||||
# Update index
|
||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||
|
||||
@@ -731,6 +748,11 @@ async def api_file_move(
|
||||
new_path_str = result["new_path"]
|
||||
item_type = result["item_type"]
|
||||
|
||||
# Journal du service d'agents (#220) — fichiers seulement (le catalogue
|
||||
# est note.*) ; les déplacements de dossiers ne sont pas émis.
|
||||
if item_type == "file":
|
||||
agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str)
|
||||
|
||||
# Update index
|
||||
if item_type == "directory":
|
||||
from backend.indexer import reload_single_vault
|
||||
|
||||
@@ -49,7 +49,7 @@ async def api_events(current_user=Depends(require_auth)):
|
||||
try:
|
||||
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
# Keepalive comment
|
||||
yield ": keepalive\n\n"
|
||||
except asyncio.CancelledError:
|
||||
|
||||
+12
-12
@@ -26,7 +26,7 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -118,7 +118,7 @@ def _validate_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(at)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None
|
||||
return {"kind": kind, "at": parsed.isoformat()}
|
||||
@@ -138,7 +138,7 @@ def create_task(
|
||||
"""Create a scheduled task. Raises ValueError on invalid action/schedule."""
|
||||
validated_action = _validate_action(action)
|
||||
validated_schedule = _validate_schedule(schedule)
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
with _lock:
|
||||
tasks = _read_tasks()
|
||||
task = {
|
||||
@@ -200,9 +200,9 @@ def delete_task(task_id: str) -> bool:
|
||||
|
||||
def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datetime:
|
||||
"""Compute the next due datetime for *task*."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
now = now or datetime.now(UTC)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
now = now.replace(tzinfo=UTC)
|
||||
schedule = task.get("schedule", {})
|
||||
kind = schedule.get("kind")
|
||||
last_run_at = task.get("last_run_at")
|
||||
@@ -211,7 +211,7 @@ def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datet
|
||||
try:
|
||||
last = datetime.fromisoformat(str(last_run_at))
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
last = last.replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
last = None
|
||||
if kind == "interval_hours":
|
||||
@@ -233,11 +233,11 @@ def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datet
|
||||
try:
|
||||
at = datetime.fromisoformat(str(schedule.get("at")))
|
||||
if at.tzinfo is None:
|
||||
at = at.replace(tzinfo=timezone.utc)
|
||||
at = at.replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
return now
|
||||
if task.get("last_run_at"):
|
||||
return datetime.max.replace(tzinfo=timezone.utc) # déjà exécutée
|
||||
return datetime.max.replace(tzinfo=UTC) # déjà exécutée
|
||||
return at
|
||||
return now + timedelta(hours=24)
|
||||
|
||||
@@ -278,7 +278,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
|
||||
return {"task_id": task_id, "skipped": True, "reason": "disabled"}
|
||||
try:
|
||||
result = _execute_action(task)
|
||||
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
|
||||
task["last_run_at"] = datetime.now(UTC).isoformat()
|
||||
task["last_status"] = "ok"
|
||||
task["last_error"] = None
|
||||
task["run_count"] = int(task.get("run_count", 0)) + 1
|
||||
@@ -292,7 +292,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
|
||||
broadcast("schedule_success", f"Tâche « {task['name']} » OK", "Exécution manuelle réussie.")
|
||||
return {"task_id": task_id, "ok": True, "result": result}
|
||||
except Exception as e:
|
||||
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
|
||||
task["last_run_at"] = datetime.now(UTC).isoformat()
|
||||
task["last_status"] = "error"
|
||||
task["last_error"] = str(e)
|
||||
task["run_count"] = int(task.get("run_count", 0)) + 1
|
||||
@@ -315,7 +315,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
|
||||
|
||||
def tick(now: datetime | None = None) -> list[dict[str, Any]]:
|
||||
"""Run every due task. Returns per-task outcomes (empty when idle)."""
|
||||
now = now or datetime.now(timezone.utc)
|
||||
now = now or datetime.now(UTC)
|
||||
outcomes: list[dict[str, Any]] = []
|
||||
for task in _read_tasks():
|
||||
if not task.get("enabled", True):
|
||||
@@ -323,7 +323,7 @@ def tick(now: datetime | None = None) -> list[dict[str, Any]]:
|
||||
try:
|
||||
next_run = datetime.fromisoformat(str(task.get("next_run_at") or ""))
|
||||
if next_run.tzinfo is None:
|
||||
next_run = next_run.replace(tzinfo=timezone.utc)
|
||||
next_run = next_run.replace(tzinfo=UTC)
|
||||
except ValueError:
|
||||
next_run = compute_next_run(task, now)
|
||||
if next_run <= now:
|
||||
|
||||
@@ -14,7 +14,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -145,7 +145,7 @@ def list_backup_files(vault_name: str, relative_path: str) -> list[dict[str, Any
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
try:
|
||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||
dt = datetime.fromtimestamp(ts, tz=UTC).isoformat()
|
||||
except (OSError, OverflowError, ValueError) as ts_err:
|
||||
logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}")
|
||||
continue
|
||||
@@ -219,7 +219,7 @@ def diff_backup(
|
||||
status=500,
|
||||
details={"vault": vault_name, "path": path, "version": ts},
|
||||
) from e
|
||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
dt = datetime.fromtimestamp(ts, tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
return content, f"{path}@{dt}"
|
||||
|
||||
try:
|
||||
|
||||
@@ -117,7 +117,7 @@ def list_recent(
|
||||
for v_name, f in recent:
|
||||
iso_modified = f.get("modified", "")
|
||||
try:
|
||||
mtime_dt = datetime.fromisoformat(iso_modified.replace("Z", "+00:00"))
|
||||
mtime_dt = datetime.fromisoformat(iso_modified)
|
||||
mtime_val = mtime_dt.timestamp()
|
||||
except Exception:
|
||||
mtime_val = time.time()
|
||||
|
||||
@@ -7,6 +7,7 @@ Single source of truth consumed by both the REST routes (``/api/vaults``,
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -49,7 +50,7 @@ def get_vault_root(vault_name: str) -> Path:
|
||||
|
||||
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
"""Return the direct children of a vault directory (directories first)."""
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from backend.indexer import SUPPORTED_EXTENSIONS
|
||||
from backend.vault_settings import get_vault_setting
|
||||
@@ -98,7 +99,7 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
"type": "file",
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
|
||||
"extension": entry.suffix.lower(),
|
||||
})
|
||||
except PermissionError:
|
||||
@@ -156,7 +157,7 @@ def list_all_files(
|
||||
ServiceError: ``not_found`` (404) if the directory does not exist,
|
||||
``permission_denied`` (403) if it cannot be read.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
from backend.indexer import IGNORED_DIRS
|
||||
|
||||
@@ -215,7 +216,7 @@ def list_all_files(
|
||||
"vault": vault_name,
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
|
||||
"extension": ext.lstrip(".") if ext else "",
|
||||
"tags": _indexed_tags(vault_name, rel_path),
|
||||
}
|
||||
|
||||
+5
-5
@@ -11,7 +11,7 @@ import json
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.share")
|
||||
@@ -58,7 +58,7 @@ def create_share(
|
||||
|
||||
expires_at = None
|
||||
if expires_in_hours:
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
|
||||
expires_at = (datetime.now(UTC) + timedelta(hours=expires_in_hours)).isoformat()
|
||||
|
||||
share = {
|
||||
"id": token,
|
||||
@@ -66,7 +66,7 @@ def create_share(
|
||||
"vault": vault,
|
||||
"path": path,
|
||||
"created_by": created_by,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"expires_at": expires_at,
|
||||
"access_count": 0,
|
||||
"last_accessed": None,
|
||||
@@ -86,7 +86,7 @@ def get_share_by_token(token: str) -> dict | None:
|
||||
return None
|
||||
if share.get("expires_at"):
|
||||
expires = datetime.fromisoformat(share["expires_at"])
|
||||
if datetime.now(timezone.utc) > expires:
|
||||
if datetime.now(UTC) > expires:
|
||||
return None
|
||||
return share
|
||||
|
||||
@@ -98,7 +98,7 @@ def record_access(token: str):
|
||||
share = data["shares"].get(token)
|
||||
if share:
|
||||
share["access_count"] = share.get("access_count", 0) + 1
|
||||
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
|
||||
share["last_accessed"] = datetime.now(UTC).isoformat()
|
||||
_write(data)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Reuses the application audit log (``data/audit.log``, JSON lines) and adds an
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from backend.audit import _write_entry
|
||||
@@ -41,7 +41,7 @@ def log_tool_call(
|
||||
) -> None:
|
||||
"""Append an ``ai_tool_call`` entry to the audit log."""
|
||||
_write_entry({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"action": "ai_tool_call",
|
||||
"username": username,
|
||||
"ip": ip or "unknown",
|
||||
|
||||
@@ -14,6 +14,7 @@ import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from backend import agent_journal
|
||||
from backend.indexer import get_backlinks as _get_backlinks
|
||||
from backend.indexer import get_vault_names
|
||||
from backend.services.backups import diff_backup as _diff_backup
|
||||
@@ -58,7 +59,7 @@ from backend.services.search import advanced_search_vaults, search_vaults
|
||||
from backend.services.search import list_tags as _list_tags
|
||||
from backend.services.search import search_paths as _search_paths
|
||||
from backend.services.vaults import browse_directory, list_accessible_vaults, list_all_files
|
||||
from backend.tools.context import ToolContext, ToolRisk
|
||||
from backend.tools.context import ToolContext, ToolMode, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import (
|
||||
AppendToFileInput,
|
||||
@@ -360,6 +361,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
|
||||
# ── D. Mutations ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _acteur_journal(ctx: ToolContext) -> str:
|
||||
"""Acteur journal (#220) : ``agent:mcp`` quand l'appel vient du
|
||||
serveur MCP d'ObsiGate, ``user`` pour l'assistant intégré."""
|
||||
return agent_journal.ACTEUR_MCP if ctx.mode == ToolMode.MCP else agent_journal.ACTEUR_USER
|
||||
|
||||
|
||||
@tool(
|
||||
name="create_file",
|
||||
description=(
|
||||
@@ -374,7 +381,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
|
||||
)
|
||||
def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]:
|
||||
"""Create a vault file (fails if it already exists)."""
|
||||
return _create_file(params.vault, params.path, params.content)
|
||||
result = _create_file(params.vault, params.path, params.content)
|
||||
agent_journal.emettre(
|
||||
agent_journal.TYPE_CREE, params.vault, result.get("path", params.path),
|
||||
actor=_acteur_journal(ctx),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -402,7 +414,12 @@ def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str
|
||||
)
|
||||
def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
|
||||
"""Replace a vault file's content."""
|
||||
return _edit_file(params.vault, params.path, params.content)
|
||||
result = _edit_file(params.vault, params.path, params.content)
|
||||
agent_journal.emettre(
|
||||
agent_journal.TYPE_EDITE, params.vault, result.get("path", params.path),
|
||||
actor=_acteur_journal(ctx),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -414,7 +431,12 @@ def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
|
||||
)
|
||||
def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]:
|
||||
"""Append content to a vault file."""
|
||||
return _append_to_file(params.vault, params.path, params.content)
|
||||
result = _append_to_file(params.vault, params.path, params.content)
|
||||
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
|
||||
agent_journal.emettre(
|
||||
agent_journal.TYPE_EDITE, params.vault, chemin, actor=_acteur_journal(ctx),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -426,7 +448,12 @@ def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any
|
||||
)
|
||||
def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]:
|
||||
"""Rename a vault file."""
|
||||
return _rename_file(params.vault, params.path, params.new_name)
|
||||
result = _rename_file(params.vault, params.path, params.new_name)
|
||||
agent_journal.emettre(
|
||||
agent_journal.TYPE_DEPLACE, params.vault,
|
||||
result.get("new_path", params.path), actor=_acteur_journal(ctx),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -450,7 +477,14 @@ def rename_directory(ctx: ToolContext, params: RenameDirectoryInput) -> dict[str
|
||||
)
|
||||
def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]:
|
||||
"""Move a vault file or directory."""
|
||||
return _move_path(params.vault, params.source_path, params.destination_dir)
|
||||
result = _move_path(params.vault, params.source_path, params.destination_dir)
|
||||
# Journal (#220) : fichiers seulement — les dossiers sont hors catalogue.
|
||||
if result.get("item_type") == "file":
|
||||
agent_journal.emettre(
|
||||
agent_journal.TYPE_DEPLACE, params.vault,
|
||||
result.get("new_path", params.source_path), actor=_acteur_journal(ctx),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -497,7 +531,12 @@ def replace_in_files(ctx: ToolContext, params: ReplaceInFilesInput) -> dict[str,
|
||||
)
|
||||
def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]:
|
||||
"""Delete a vault file."""
|
||||
return _delete_file(params.vault, params.path)
|
||||
result = _delete_file(params.vault, params.path)
|
||||
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
|
||||
agent_journal.emettre(
|
||||
agent_journal.TYPE_SUPPRIME, params.vault, chemin, actor=_acteur_journal(ctx),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@tool(
|
||||
|
||||
+1
-1
@@ -244,7 +244,7 @@ class VaultWatcher:
|
||||
key = event.get('dest') or event['src']
|
||||
pending[key] = event
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
if pending:
|
||||
events_to_process = list(pending.values())
|
||||
pending.clear()
|
||||
|
||||
+4
-4
@@ -31,7 +31,7 @@ import os
|
||||
import socket
|
||||
import threading
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -275,7 +275,7 @@ def create_webhook(
|
||||
"url": url,
|
||||
"events": [e for e in events if e in VALID_EVENTS],
|
||||
"enabled": True,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"last_fired_at": None,
|
||||
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
|
||||
"vault": _normalize_vault_filter(vault),
|
||||
@@ -344,7 +344,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
|
||||
|
||||
payload = {
|
||||
"event": event_type,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"data": data,
|
||||
}
|
||||
body = json.dumps(payload, default=str)
|
||||
@@ -369,7 +369,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
|
||||
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
|
||||
else:
|
||||
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
|
||||
update_webhook(wh["id"], {"last_fired_at": datetime.now(timezone.utc).isoformat()})
|
||||
update_webhook(wh["id"], {"last_fired_at": datetime.now(UTC).isoformat()})
|
||||
except Exception as e:
|
||||
logger.warning(f"Webhook '{wh['name']}' error: {e}")
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.71.0"
|
||||
version = "2.81.4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.71.0"
|
||||
version = "2.81.4"
|
||||
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.71.0",
|
||||
"version": "2.81.4",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -51,8 +51,6 @@ services:
|
||||
- VAULT_4_PATH=/vaults/Obsidian_WORKOUT
|
||||
- VAULT_5_NAME=Sessions
|
||||
- VAULT_5_PATH=/vaults/SessionsManager
|
||||
- DIR_1_NAME=Bruno
|
||||
- DIR_1_PATH=/vaults/bruno
|
||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Coffre Secrets ObsiGate — Contrat de distribution (Phases 3 et 4)
|
||||
|
||||
Référence du contrat pour les clients serveur-à-serveur : Spark
|
||||
(issue #21), TikTak, Flowdeck, Kanbante (issue #22). Ce document ne
|
||||
duplique aucune logique de chiffrement : le chiffrement reste
|
||||
entièrement côté ObsiGate (enveloppe KEK → DEK → secret, AES-256-GCM).
|
||||
|
||||
## 1. Endpoint
|
||||
|
||||
```
|
||||
POST /api/coffres/distribuer
|
||||
Authorization: Bearer <jeton-consommateur>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Corps (forme à plat, Phase 1b) :
|
||||
|
||||
```json
|
||||
{
|
||||
"reference": "coffre:<slug>/<nom>",
|
||||
"contexte_type": "mission",
|
||||
"contexte_id": "mission-123"
|
||||
}
|
||||
```
|
||||
|
||||
Forme imbriquée acceptée depuis la Phase 2 (client Spark) :
|
||||
|
||||
```json
|
||||
{
|
||||
"reference": "coffre:<slug>/<nom>",
|
||||
"contexte": { "type": "mission", "id": "mission-123" }
|
||||
}
|
||||
```
|
||||
|
||||
La forme à plat reste supportée ; si les deux formes sont présentes,
|
||||
la forme à plat est prioritaire. Le contexte est **obligatoire** :
|
||||
sans `contexte_type`/`contexte_id` (ou sans `contexte.type`/`id`),
|
||||
la requête est refusée avant toute authentification utile.
|
||||
|
||||
Réponse en succès (200) :
|
||||
|
||||
```json
|
||||
{ "reference": "coffre:<slug>/<nom>", "valeur": "<valeur>", "version": 1 }
|
||||
```
|
||||
|
||||
La valeur n'existe qu'en mémoire chez l'appelant : elle ne doit
|
||||
jamais être écrite dans un fichier, un journal, un prompt, une étape
|
||||
d'activité ou un livrable. Le patron imposé à chaque app est
|
||||
**distribuer → utiliser → oublier** ; à la fin de l'opération
|
||||
(fin de mission, fin de synchronisation), l'environnement qui la
|
||||
contenait est détruit et rien ne persiste.
|
||||
|
||||
Prérequis côté ObsiGate : coffres activés
|
||||
(`OBSIGATE_SECRETS_ENABLED`, sinon 503 `secrets_desactives`) et KEK
|
||||
configurée (sinon 503 `kek_absente`).
|
||||
|
||||
## 2. Authentification : consommateur nommé, jeton propre
|
||||
|
||||
- Un consommateur est créé par un admin :
|
||||
`POST /api/coffres/consommateurs` (`{"nom": "spark"}`), ou en une
|
||||
seule opération avec ses octrois :
|
||||
`POST /api/coffres/consommateurs/avec-octrois`
|
||||
(`{"nom": "tiktak", "octrois": [{"coffre_slug": "...",
|
||||
"secret_nom": "..."}]}` ; `secret_nom` absent = tout le coffre).
|
||||
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la création.
|
||||
Il n'est jamais stocké en clair (seul son hash SHA-256 est persisté)
|
||||
et jamais relisté (`GET /api/coffres/consommateurs` ne renvoie que
|
||||
`id`, `nom`, `jeton_prefixe`, `statut`, `cree_par`, `cree_le`).
|
||||
- L'`id` du consommateur sert de `jti` révocable :
|
||||
`DELETE /api/coffres/consommateurs/{id}` révoque immédiatement
|
||||
toutes ses distributions, sans toucher aux autres consommateurs.
|
||||
- Ce jeton est **distinct** du jeton MCP de notes et de la session
|
||||
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).
|
||||
- Ajouter une app = enregistrer un consommateur + créer des octrois,
|
||||
**sans modification du coffre** et sans toucher à ses secrets.
|
||||
|
||||
## 3. Cas Spark (issue #21)
|
||||
|
||||
- Consommateur nommé `spark` (convention, non imposée par le code :
|
||||
l'autorisation porte sur le jeton, pas sur le nom).
|
||||
- Contexte : `contexte_type = "mission"`, `contexte_id` = identifiant
|
||||
de la mission. La forme imbriquée
|
||||
`contexte: {type: "mission", id: <id-mission>}` est acceptée.
|
||||
- Flux côté agent-commun (hors ObsiGate, rappelé ici pour la
|
||||
correspondance des erreurs) : le LLM ne voit que la référence
|
||||
`coffre:<slug>/<nom>` et un accès oui/non, jamais la valeur. Sans
|
||||
octroi, la mission passe en `acces_requis` en nommant l'accès
|
||||
manquant. Avec octroi et mission approuvée (enveloppe budgétaire),
|
||||
le service Spark appelle `/distribuer` et injecte la valeur en
|
||||
variable d'environnement de l'exécuteur, jamais dans le prompt.
|
||||
Fin de mission = environnement détruit.
|
||||
- Révocation dans ObsiGate (consommateur ou octroi) : la mission
|
||||
suivante repasse en `acces_requis`. Limite assumée : une mission
|
||||
déjà démarrée conserve son environnement jusqu'à sa fin.
|
||||
|
||||
## 4. Erreurs (codes `detail`, stables depuis la Phase 1b)
|
||||
|
||||
| HTTP | `detail` | Sens | Correspondance Spark |
|
||||
| --- | --- | --- | --- |
|
||||
| 400 | `contexte_manquant` | Contexte absent ou vide. | Erreur d'appel, pas un état d'accès. |
|
||||
| 400 | `reference_invalide` | Référence mal formée ou nom invalide. | Erreur d'appel. |
|
||||
| 401 | `consommateur_invalide` | Jeton inconnu (ni MCP, ni session). | Configurer le jeton consommateur. |
|
||||
| 403 | `consommateur_revoque` | Consommateur révoqué. | `acces_requis` (ré-accorder). |
|
||||
| 403 | `octroi_absent` | Aucun octroi (ou secret/coffre inexistant — anti-énumération, volontairement indistinct). | `acces_requis` (demander l'octroi). |
|
||||
| 403 | `octroi_revoque` | Octroi révoqué. | `acces_requis` (ré-accorder). |
|
||||
| 403 | `octroi_expire` | Octroi expiré (défaut 90 jours à la création). | `acces_requis` (renouveler). |
|
||||
| 410 | `secret_expire` | Secret expiré, même avec un octroi actif. | Rotation du secret côté ObsiGate. |
|
||||
| 503 | `secrets_desactives` / `kek_absente` / `crypto_indisponible` | Coffre désactivé, KEK absente, ou bibliothèque absente. | Panne ObsiGate, pas un refus d'accès. |
|
||||
|
||||
Un octroi coffre-wide (`secret_id` nul) couvre tous les secrets du
|
||||
coffre ; un octroi par secret ne couvre que ce secret. Un octroi
|
||||
révoqué ne doit jamais être masqué par l'absence d'un autre octroi
|
||||
(priorité : ok > révoqué > expiré > absent).
|
||||
|
||||
## 5. Audit
|
||||
|
||||
Chaque distribution est auditée (`distribution_ok` ou
|
||||
`distribution_refusee`, avec référence, consommateur, contexte et
|
||||
code de résultat). **Aucune entrée d'audit ne contient jamais une
|
||||
valeur, un texte chiffré, un nonce ou un jeton.** La route journal
|
||||
(`GET /api/coffres/{slug}/journal`) filtre en défense en profondeur
|
||||
ces champs même s'ils apparaissaient un jour dans une entrée.
|
||||
La copie manuelle à usage unique (admin, via consommateur temporaire
|
||||
créé puis révoqué immédiatement, valeur copiée au presse-papiers
|
||||
sans affichage) est auditée comme une distribution de contexte
|
||||
`copie_manuelle`, avec la trace distincte de ce contexte.
|
||||
|
||||
## 6. Limites et restes à faire (Phases 3–4, côté ObsiGate)
|
||||
|
||||
- **Isolation workspace Flowdeck** : le type de cible `workspace`
|
||||
existe pour les octrois, mais `/distribuer` n'autorise aujourd'hui
|
||||
que les octrois de type `consommateur`. L'isolation « un workspace
|
||||
ne peut pas utiliser l'octroi d'un autre » repose donc, pour
|
||||
l'instant, sur le serveur Flowdeck lui-même (il ne demande que les
|
||||
références de son workspace, avec son propre jeton consommateur).
|
||||
Un contrôle serveur strict (contexte workspace vérifié côté
|
||||
ObsiGate contre un octroi `workspace`) reste à spécifier et à
|
||||
implémenter si Bruno l'exige — il changerait la sémantique
|
||||
d'autorisation de `/distribuer` et doit être coordonné avec les
|
||||
apps clientes avant tout changement.
|
||||
- **Distribution par session** : non implémentée, volontairement.
|
||||
Elle contredirait la règle Phase 1b (distribution réservée aux
|
||||
consommateurs) et exigerait une ré-authentification qui n'existe
|
||||
pas encore dans ObsiGate. La copie manuelle reste le patron
|
||||
supporté (consommateur temporaire, admin seulement).
|
||||
- **Clients par app** (TikTak, Flowdeck, Kanbante, agent-commun) :
|
||||
à implémenter dans chaque dépôt, en branche + PR, jamais mergés
|
||||
sans go. Patron commun : le serveur distribue, utilise, oublie ;
|
||||
aucune valeur en cache ; si ObsiGate est indisponible, l'opération
|
||||
échoue proprement, sans repli sur une ancienne valeur.
|
||||
- **Lecteurs internes ObsiGate** (`ai.py`, `tools/secrets.py`) :
|
||||
toujours branchés sur `api_keys.json` en clair (voir le document
|
||||
de sauvegarde/restauration, §5) ; leur branchement sur le coffre
|
||||
est un travail séparé de la présente Phase 2.
|
||||
@@ -0,0 +1,390 @@
|
||||
# Coffre Secrets ObsiGate — Déploiement et mise en service (Phase 5, issue #23)
|
||||
|
||||
Document de déploiement du coffre Secrets, issu des Phases 1a/1b
|
||||
(issues #17, #18), 2 (issue #20), 3 (issue #21, côté agent-commun)
|
||||
et 4 (issue #22, côté TikTak, Flowdeck, Kanbante).
|
||||
|
||||
**Règles de gouvernance (non négociables) :** travail en branche +
|
||||
pull request, jamais sur `main`, jamais de merge, de release, de tag,
|
||||
de fermeture d'issue ni de changement de label sans le go explicite
|
||||
de Bruno. Ce document ne décrit aucune valeur réelle : les exemples
|
||||
utilisent des valeurs fictives (`TEST-VALUE-XXXX`) et aucune KEK
|
||||
n'est jamais écrite ici, ni dans un journal, ni dans une note, ni
|
||||
dans le dépôt.
|
||||
|
||||
Documents compagnons (même branche de référence, Phase 2) :
|
||||
|
||||
- `docs/COFFRE-contrat-distribution.md` — contrat
|
||||
`POST /api/coffres/distribuer` pour Spark, TikTak, Flowdeck,
|
||||
Kanbante (authentification par consommateur, contextes, codes
|
||||
d'erreur stables).
|
||||
- `docs/COFFRE-sauvegarde-restauration.md` — sauvegarde
|
||||
(`data/coffres.json` + `data/coffres_valeurs.json`, 0600) et
|
||||
restauration sur instance vierge, perte de KEK.
|
||||
|
||||
## 1. Prérequis et variables d'environnement
|
||||
|
||||
### 1.1 Dépendance
|
||||
|
||||
- `cryptography>=44.0` dans `backend/requirements.txt` (dépendance
|
||||
Phase 1a). Sans elle, les opérations de chiffrement répondent
|
||||
503 `crypto_indisponible` : c'est un mode dégradé explicite, pas
|
||||
un repli en clair. Il n'y a jamais de stockage ni de distribution
|
||||
de valeur en clair.
|
||||
- Chiffrement par enveloppe AES-256-GCM : KEK (256 bits) → DEK par
|
||||
coffre → valeur du secret. Un tag GCM invalide refuse le
|
||||
déchiffrement (testé : `tests/test_coffre_secrets.py`,
|
||||
`test_tag_invalide_refuse`).
|
||||
|
||||
### 1.2 Variables
|
||||
|
||||
| Variable | Rôle | Défaut |
|
||||
| --- | --- | --- |
|
||||
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Tant qu'elle n'est pas à `true`, **toutes** les routes coffre répondent 503 `secrets_desactives`. | désactivé |
|
||||
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). **Prioritaire** sur le fichier si les deux sont définis. | absente |
|
||||
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier contenant la KEK, **en 0600, hors dépôt et hors arborescence des vaults**. Un fichier trop permissif (droits groupe/autre) est refusé explicitement (`KEKInvalideError`). | absent |
|
||||
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut) : `coffres.json`, `coffres_valeurs.json` et l'historique `api_keys.json`. | `data` |
|
||||
|
||||
La KEK est générée une seule fois (admin, coffres activés) via
|
||||
`POST /api/coffres/admin/generer-kek` ; ObsiGate **ne la stocke
|
||||
jamais**. Au moment de sa génération : la copier immédiatement
|
||||
dans un gestionnaire de mots de passe ou sur un support hors ligne,
|
||||
hors du serveur ; vérifier la copie en la relisant depuis le
|
||||
gestionnaire, jamais depuis l'écran qui ne l'affichera plus.
|
||||
Ne jamais la coller dans le dépôt, dans une note ObsiGate, dans le
|
||||
même dossier que `data/`, ni dans un journal.
|
||||
|
||||
**Recommandation de déploiement : préférer `OBSIGATE_KEK_FILE`**
|
||||
(fichier 0600) à `OBSIGATE_KEK` en variable d'environnement, pour
|
||||
éviter que la KEK apparaisse dans un historique de processus ou un
|
||||
dump d'environnement. Si les deux sont définis, `OBSIGATE_KEK`
|
||||
l'emporte — vérifier qu'aucune ancienne valeur ne traîne dans
|
||||
l'environnement au moment de basculer sur le fichier.
|
||||
|
||||
### 1.3 Ce que la sauvegarde contient (rappel)
|
||||
|
||||
Deux fichiers seulement portent le coffre, tous deux en 0600 :
|
||||
|
||||
- `data/coffres.json` : métadonnées des coffres, DEK chiffrées,
|
||||
consommateurs (hashes SHA-256 des jetons seulement, jamais le
|
||||
jeton en clair), octrois, métadonnées des secrets ;
|
||||
- `data/coffres_valeurs.json` : versions chiffrées des secrets.
|
||||
|
||||
Copiés seuls, ils ne donnent accès à aucune valeur. La KEK ne fait
|
||||
pas partie de la sauvegarde : elle est copiée à part, hors serveur.
|
||||
Sauvegarder les deux fichiers et la KEK au même endroit annulerait
|
||||
la protection du chiffrement.
|
||||
|
||||
## 2. Décisions ouvertes de la spec — tranchées par défaut
|
||||
|
||||
Les décisions ouvertes de la spec (§15, §16), rappelées dans
|
||||
l'issue #23, sont confirmées comme tranchées selon les défauts
|
||||
recommandés, en attendant une décision contraire explicite de
|
||||
Bruno. Aucune d'elles ne nécessite de changement de code pour la
|
||||
mise en service décrite ci-dessous :
|
||||
|
||||
| Décision | Choix confirmé par défaut |
|
||||
| --- | --- |
|
||||
| Glyphe du nœud Secrets | Cadenas U+1F510 (🔐), distinct de la maison du Home et du diamant du Partage. Le nœud est un nœud virtuel sous le Home de l'utilisateur courant seulement (voir §4). |
|
||||
| Stockage de la KEK | Par fichier 0600, hors dépôt (`OBSIGATE_KEK_FILE`) ; `OBSIGATE_KEK` reste supportée comme solution de secours. |
|
||||
| Expiration des octrois | 90 jours par défaut à la création, renouvelable (création d'un nouvel octroi). Un octroi expiré refuse la distribution (`octroi_expire`, 403) ; un octroi révoqué refuse dès l'appel suivant (`octroi_revoque`, 403). |
|
||||
| Copie manuelle | Presse-papiers seul, sans affichage de la valeur. Patron supporté : consommateur temporaire créé par un admin puis révoqué immédiatement, auditée comme distribution de contexte `copie_manuelle`. **Aucun endpoint dédié de copie par session n'est ajouté** : il contredirait la règle « distribution réservée aux consommateurs » et exigerait une ré-authentification qui n'existe pas encore — décision volontaire, à trancher avec Bruno avant toute implémentation. |
|
||||
| Coffres partagés | Phase 2 ultérieure. En mise en service : coffres personnels d'abord, plus le coffre d'instance « ObsiGate interne » (slug `obsigate-interne`, admin seulement) issu de l'import. |
|
||||
|
||||
## 3. Mise en service pas à pas
|
||||
|
||||
**Ordre impératif.** Chaque étape est validée avant de passer à la
|
||||
suivante. En particulier : **les lecteurs internes sont branchés
|
||||
sur le coffre AVANT toute purge du clair** (étape 4 avant étape 7),
|
||||
et la purge n'a lieu qu'après validation complète (étape 6).
|
||||
|
||||
### Étape 0 — Préparer, coffre encore désactivé
|
||||
|
||||
1. Déployer le code coffre (Phases 1–2) sur l'instance, **sans
|
||||
activer le flag** : `OBSIGATE_SECRETS_ENABLED` reste absent ou à
|
||||
`false`. Vérifier que toutes les routes `/api/coffres/*`
|
||||
répondent 503.
|
||||
2. Sauvegarder l'état actuel : copie de `data/` (dont
|
||||
`api_keys.json`) et de la configuration, hors serveur.
|
||||
3. Générer la KEK (voir §1.2) et la copier hors serveur. Ne pas
|
||||
encore la référencer dans la configuration de l'instance si
|
||||
l'étape 1 n'est pas terminée — une KEK présente sans flag actif
|
||||
ne distribue rien, mais sa présence doit rester traçable.
|
||||
|
||||
### Étape 1 — Créer les consommateurs et les octrois
|
||||
|
||||
Avant d'activer le flag, préparer les consommateurs nommés, un par
|
||||
application cliente (convention de nommage, non imposée par le
|
||||
code — l'autorisation porte sur le jeton, pas sur le nom) :
|
||||
|
||||
| Consommateur | Usage | Contexte de distribution |
|
||||
| --- | --- | --- |
|
||||
| `spark` | Agent Spark (agent-commun, Phase 3) | `contexte_type = "mission"`, `contexte_id` = id de mission (forme imbriquée `contexte: {type, id}` acceptée) |
|
||||
| `tiktak` | Synchronisation TikTak (Phase 4) | `contexte_type = "tiktak_sync"`, `contexte_id` = id utilisateur |
|
||||
| `flowdeck` | Connecteurs Flowdeck (Phase 4) | Contexte workspace, vérifié côté Flowdeck (voir §5) |
|
||||
| `kanbante` | Webhooks Kanbante (Phase 4) | Contexte webhook, distribution à chaque appel puis oubli |
|
||||
|
||||
Création en une seule opération avec les octrois :
|
||||
|
||||
```
|
||||
POST /api/coffres/consommateurs/avec-octrois
|
||||
{"nom": "tiktak", "octrois": [{"coffre_slug": "...", "secret_nom": "..."}]}
|
||||
```
|
||||
|
||||
- `secret_nom` absent = tout le coffre (octroi coffre-wide).
|
||||
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la
|
||||
création. Le noter immédiatement dans le coffre de
|
||||
configuration de l'application cliente concernée, jamais dans
|
||||
une note ObsiGate ni dans le dépôt. Il n'est jamais relisté :
|
||||
`GET /api/coffres/consommateurs` ne renvoie que `id`, `nom`,
|
||||
`jeton_prefixe`, `statut`, `cree_par`, `cree_le`.
|
||||
- La révocation est immédiate : `DELETE
|
||||
/api/coffres/consommateurs/{id}` révoque toutes les
|
||||
distributions de ce consommateur, sans toucher aux autres.
|
||||
Une mission Spark déjà démarrée conserve son environnement
|
||||
jusqu'à sa fin ; la mission suivante repasse en `acces_requis`.
|
||||
|
||||
À ce stade, les consommateurs et octrois peuvent être créés alors
|
||||
que les secrets n'existent pas encore : les octrois pointent vers
|
||||
des coffres/secrets à créer aux étapes suivantes. Vérifier la
|
||||
liste des consommateurs et des octrois avant d'activer le flag.
|
||||
|
||||
### Étape 2 — Activer le flag
|
||||
|
||||
1. Définir `OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt) puis
|
||||
`OBSIGATE_SECRETS_ENABLED=true`.
|
||||
2. Redémarrer l'instance.
|
||||
3. Vérifier : la vue Secrets s'ouvre ; `GET /api/coffres` répond
|
||||
200 (liste vide ou coffres existants) ; sans KEK valide, les
|
||||
routes répondent 503 `kek_absente` — dans ce cas, corriger la
|
||||
KEK avant toute création de secret.
|
||||
|
||||
### Étape 3 — Importer l'existant (admin seulement)
|
||||
|
||||
Bouton « Importer l'existant » de la vue Secrets (admin
|
||||
seulement), en deux temps séparés :
|
||||
|
||||
1. **Aperçu** : `GET /api/coffres/migration/api-keys` liste les
|
||||
candidats de `data/api_keys.json` (clés IA et clés d'outils).
|
||||
2. **Import** : `POST /api/coffres/migration/api-keys/import`
|
||||
copie les entrées avec valeur vers le coffre d'instance
|
||||
« ObsiGate interne » (slug `obsigate-interne`), chiffrées.
|
||||
Un secret déjà présent n'est **jamais écrasé**.
|
||||
`api_keys.json` n'est **pas modifié** par l'import.
|
||||
|
||||
Vérifier après import : les secrets importés apparaissent dans la
|
||||
vue Secrets avec `has_value = true`, et aucune valeur n'est
|
||||
visible (métadonnées seules : nom, type, version, dates, octrois
|
||||
actifs — jamais de valeur, jamais de texte chiffré, jamais de
|
||||
nonce).
|
||||
|
||||
### Étape 4 — Brancher les lecteurs internes AVANT toute purge
|
||||
|
||||
**Point de vigilance principal de la mise en service.** Les
|
||||
lecteurs actuels d'ObsiGate (`backend/ai.py`,
|
||||
`backend/tools/secrets.py`) lisent encore `data/api_keys.json`
|
||||
directement ; ils ne sont pas encore branchés sur le coffre
|
||||
(reste à faire explicite des Phases 2–5). Tant que ce branchement
|
||||
n'est pas livré et validé, **purger le clair priverait l'IA et les
|
||||
outils de leurs clés**.
|
||||
|
||||
Ordre à respecter :
|
||||
|
||||
1. Ne pas purger.
|
||||
2. Livrer (en branche + PR, jamais mergé sans go) le branchement
|
||||
des lecteurs internes sur une distribution depuis le coffre
|
||||
d'instance, puis le valider sur l'instance : l'IA et les outils
|
||||
fonctionnent avec `api_keys.json` rendu temporairement
|
||||
inaccessible en lecture (test de coupure contrôlée), ou à
|
||||
défaut avec un secret de test distribué depuis le coffre.
|
||||
3. Seulement après cette validation, passer à l'étape 7.
|
||||
|
||||
Si Bruno accepte une interruption temporaire de l'IA/outils, le
|
||||
documenter explicitement avant la purge ; sinon, la purge attend
|
||||
le branchement.
|
||||
|
||||
### Étape 5 — Brancher les applications clientes
|
||||
|
||||
Pour chaque application (branches dédiées, jamais mergées sans
|
||||
go), appliquer le patron commun **distribuer → utiliser →
|
||||
oublier** : le serveur distribue à chaque opération, utilise la
|
||||
valeur en mémoire, puis l'oublie ; aucune valeur en cache, aucune
|
||||
persistance. Si ObsiGate est indisponible, l'opération échoue
|
||||
proprement, sans repli sur une ancienne valeur.
|
||||
|
||||
- **Spark (agent-commun, Phase 3)** : identités référencées
|
||||
`coffre:<slug>/<nom>` (slug 3–64, nom 3–128, minuscules /
|
||||
chiffres / `-` / `_`), sans colonne valeur ; le LLM ne voit que
|
||||
la référence et un accès oui/non. Sans octroi, la mission passe
|
||||
en `acces_requis` en nommant l'accès manquant ; avec octroi et
|
||||
mission approuvée (enveloppe budgétaire), distribution puis
|
||||
injection en variable d'environnement de l'exécuteur, jamais
|
||||
dans le prompt, jamais dans les étapes d'activité. Fin de
|
||||
mission = environnement détruit.
|
||||
- **TikTak (Phase 4)** : référence seule stockée ; distribution
|
||||
serveur à chaque synchro ; masquage côté Android (ni référence
|
||||
complète ni valeur dans les réponses vues par une clé `tck_`).
|
||||
Les configurations existantes sont marquées `a_resaisir = 1`
|
||||
(migration 019 : colonne en clair supprimée, référence vide) :
|
||||
il n'y a **pas de conversion automatique** — chaque
|
||||
configuration doit être re-saisie par référence.
|
||||
- **Flowdeck (Phase 4)** : `workspace_id` + référence sur les
|
||||
connecteurs ; distribution en mémoire ; isolation A vs B
|
||||
testée (un workspace ne distribue pas avec l'octroi d'un autre).
|
||||
Les connecteurs natifs Gitea/GitHub et les clés LLM ne sont pas
|
||||
basculés ; pas de sélecteur UI (API seulement).
|
||||
- **Kanbante (Phase 4)** : secret webhook migré en référence,
|
||||
distribuée à chaque webhook puis oubliée ; client générique
|
||||
réutilisable. Les jetons forge chiffrés Fernet ne sont **pas**
|
||||
migrés (distribution par appel requise, incompatible avec le
|
||||
cache `ForgeRegistry`) ; les secrets webhook historiques en
|
||||
clair restent acceptés par compatibilité, sans migration
|
||||
automatique.
|
||||
|
||||
### Étape 6 — Valider (avant purge)
|
||||
|
||||
Exécuter la checklist de validation de l'instance réelle (§4).
|
||||
Tant qu'un point échoue, ne pas purger et ne pas considérer la
|
||||
mise en service comme terminée.
|
||||
|
||||
### Étape 7 — Purger, seulement après validation
|
||||
|
||||
`POST /api/coffres/migration/api-keys/purger`, **après
|
||||
confirmation explicite** (`confirmer = true`), et uniquement pour
|
||||
les noms migrés. Sans confirmation, rien n'est supprimé — jamais
|
||||
automatiquement.
|
||||
|
||||
Après purge, vérifier :
|
||||
|
||||
- `api_keys.json` ne contient plus les noms purgés ;
|
||||
- l'IA et les outils fonctionnent toujours (lecteurs branchés,
|
||||
étape 4) ;
|
||||
- une distribution de test via un consommateur existant aboutit
|
||||
(succès seul vérifié, valeur ni affichée ni consignée).
|
||||
|
||||
### Étape 8 — Sauvegarder et tester la restauration
|
||||
|
||||
1. Sauvegarder `data/coffres.json` + `data/coffres_valeurs.json`
|
||||
(0600) par la sauvegarde habituelle du serveur, et la KEK à
|
||||
part, hors serveur (§1.3).
|
||||
2. Rejouer la restauration sur une instance vierge (procédure
|
||||
`docs/COFFRE-sauvegarde-restauration.md` §3 : arrêt de la
|
||||
cible, copie des deux fichiers en 0600, même KEK, redémarrage,
|
||||
vérification `has_value = true` + distribution de test +
|
||||
`distribution_ok` dans le journal). La suite automatisée
|
||||
(`tests/test_coffre_secrets.py`, classe
|
||||
`TestRestaurationVierge`) simule cette restauration ; elle ne
|
||||
remplace pas un essai sur instance réelle.
|
||||
3. En cas de perte de KEK : perte d'accès assumée, par
|
||||
construction — aucun moyen de récupérer les valeurs. Recréer
|
||||
les coffres avec une nouvelle KEK, régénérer les valeurs côté
|
||||
fournisseur, révoquer les anciennes côté fournisseur, recréer
|
||||
consommateurs et octrois. Ne jamais tenter de « réparer » les
|
||||
anciens fichiers avec une nouvelle KEK (le déchiffrement
|
||||
échouera sur tag GCM invalide, ce qui est attendu).
|
||||
|
||||
## 4. Checklist de validation par Bruno sur instance réelle
|
||||
|
||||
Validation exigée par l'issue #23 avant tout usage réel. Chaque
|
||||
point est validé sur capture, comme pour les autres livraisons :
|
||||
|
||||
- [ ] **Sidebar** : le nœud « Secrets » (cadenas U+1F510)
|
||||
apparaît sous le Home de l'utilisateur courant seulement, et
|
||||
pas sous les autres vaults ; cliquer dessus ouvre la vue
|
||||
Secrets sans naviguer dans les fichiers.
|
||||
- [ ] **Secret de test** : création d'un secret de test
|
||||
(valeur fictive, ex. `TEST-VALUE-XXXX`) dans un coffre
|
||||
personnel, puis lecture : métadonnées seules, aucune valeur
|
||||
affichée, aucun texte chiffré, aucun nonce.
|
||||
- [ ] **Journal d'audit** : après une distribution de test, le
|
||||
journal contient `distribution_ok` pour la référence, et un
|
||||
`grep` du journal sur la valeur de test ne renvoie **rien**
|
||||
(ni valeur, ni texte chiffré, ni nonce, ni jeton).
|
||||
- [ ] **Autorisations** : un admin ne voit pas le coffre
|
||||
personnel d'autrui ; une ACL `*` sur un vault ne donne aucun
|
||||
droit sur un coffre ; une session utilisateur, même admin, ne
|
||||
distribue pas (401 `consommateur_invalide`).
|
||||
- [ ] **Distribution** : contexte manquant refusé (400
|
||||
`contexte_manquant`) ; octroi révoqué refusé dès l'appel
|
||||
suivant (403 `octroi_revoque`) ; octroi expiré refusé (403
|
||||
`octroi_expire`) ; secret expiré refusé (410 `secret_expire`).
|
||||
- [ ] **Import / purge** : l'import ne modifie pas
|
||||
`api_keys.json` et n'écrase pas un secret existant ; la purge
|
||||
sans confirmation explicite ne supprime rien.
|
||||
- [ ] **Restauration** : sauvegarde + KEK externe restaurées sur
|
||||
une instance vierge, distributions fonctionnelles.
|
||||
- [ ] **Intégrations** : mission Spark en `acces_requis` sans
|
||||
octroi, puis injection exécuteur sans trace dans les étapes ;
|
||||
synchro TikTak sans valeur côté Android ; isolation workspace
|
||||
Flowdeck (A ne distribue pas avec l'octroi de B).
|
||||
- [ ] **Modale** : la modale de création de secret n'est plus
|
||||
coupée en haut (`.obsigate-modal` bornée au viewport, corps
|
||||
seul défilant, en-tête et pied toujours visibles).
|
||||
|
||||
## 5. Limites connues à la mise en service
|
||||
|
||||
À lire avant de déclarer la mise en service terminée :
|
||||
|
||||
- **Lecteurs internes non branchés** (`ai.py`,
|
||||
`tools/secrets.py` lisent encore `api_keys.json` en clair) :
|
||||
purger avant de les brancher priverait l'IA/outils de leurs
|
||||
clés (§3, étape 4).
|
||||
- **Endpoint dédié de copie manuelle par session non ajouté**
|
||||
(décision volontaire, §2) : la copie manuelle passe par un
|
||||
consommateur temporaire admin, presse-papiers seul.
|
||||
- **Isolation workspace Flowdeck non contrôlée côté ObsiGate** :
|
||||
`/distribuer` n'autorise aujourd'hui que les octrois de type
|
||||
`consommateur` ; l'isolation workspace repose sur le serveur
|
||||
Flowdeck lui-même (il ne demande que les références de son
|
||||
workspace, avec son propre jeton). Un contrôle serveur strict
|
||||
reste à spécifier si Bruno l'exige.
|
||||
- **Identités agent-commun** : détenteur libre hors coffre
|
||||
conservé (historique) ; révocation Identités non propagée vers
|
||||
ObsiGate (pas d'endpoint) ; exécuteurs CLI synchrones
|
||||
uniquement.
|
||||
- **TikTak** : « sélecteur » = saisie validée, pas de liste
|
||||
déroulante (aucune API de listage côté ObsiGate) ;
|
||||
configurations existantes à re-saisir, pas de conversion
|
||||
automatique.
|
||||
- **Flowdeck** : natifs Gitea/GitHub et clés LLM non basculés ;
|
||||
pas de sélecteur UI (API seulement) ; `workspace_id` pas encore
|
||||
branché depuis AgentEngine.
|
||||
- **Kanbante** : jetons forge chiffrés Fernet non migrés ;
|
||||
pas de migration automatique des secrets webhook historiques.
|
||||
- **Recherche / graphe / index** : par construction, un coffre
|
||||
n'est pas un vault de notes — il ne passe ni par l'indexeur, ni
|
||||
par la recherche, ni par le graphe, et aucun outil MCP de secret
|
||||
n'existe. Aucune métadonnée de secret ne doit apparaître dans
|
||||
ces index (vérification par revue de code, pas de suite
|
||||
automatisée dédiée — voir le bilan Phase 5).
|
||||
|
||||
## 6. Repli (rollback)
|
||||
|
||||
- Désactiver le flag (`OBSIGATE_SECRETS_ENABLED` absent ou
|
||||
`false`) : toutes les routes coffre répondent 503, les fichiers
|
||||
`coffres.json` / `coffres_valeurs.json` restent intacts et
|
||||
re-distribueront dès réactivation avec la même KEK.
|
||||
- Révoquer un consommateur (`DELETE
|
||||
/api/coffres/consommateurs/{id}`) coupe immédiatement ses
|
||||
distributions, sans toucher aux autres consommateurs ni aux
|
||||
secrets.
|
||||
- Ne jamais « réparer » des fichiers chiffrés avec une nouvelle
|
||||
KEK, ne jamais restaurer `coffres.json` sans
|
||||
`coffres_valeurs.json` (et inversement), ne jamais copier la
|
||||
KEK au même endroit que la sauvegarde.
|
||||
|
||||
## 7. Références
|
||||
|
||||
- Spec « Coffre de secrets ObsiGate » §6 (autorisations), §7/§10/§11
|
||||
(API), §13/§14 (sauvegarde/restauration), §15/§16 (tests,
|
||||
déploiement).
|
||||
- Issues ObsiGate : #17 (Phase 1a), #18 (Phase 1b), #19 (Phase 1c
|
||||
UI), #20 (Phase 2), #21 (Phase 3, Spark), #22 (Phase 4, TikTak /
|
||||
Flowdeck / Kanbante), #23 (Phase 5, ce document).
|
||||
- Suites de tests : `tests/test_coffre_secrets.py` (ObsiGate, 35
|
||||
tests), `tests/test_identites_coffre_phase3.py` (agent-commun,
|
||||
16 tests), `tests/test_coffre_reference.py` (TikTak, 17 fonctions
|
||||
/ 26 cas), `tests/test_coffre_reference_flowdeck.py` (Flowdeck,
|
||||
16 tests), `tests/test_coffre_client.py` (Kanbante, 11 fonctions
|
||||
/ 25 cas). Voir le bilan Phase 5 pour la matrice de couverture
|
||||
complète et les manques.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Coffre Secrets ObsiGate — Sauvegarde et restauration (Phase 2, issue #20)
|
||||
|
||||
Ce document décrit la seule procédure supportée pour sauvegarder et
|
||||
restaurer le coffre Secrets, ainsi que le cas de perte de la KEK.
|
||||
Il complète la spécification du coffre (§13, §14) ; il ne décrit
|
||||
aucune valeur réelle — les exemples utilisent des valeurs fictives.
|
||||
|
||||
## 1. Ce qui constitue une sauvegarde
|
||||
|
||||
Deux fichiers, et seulement ces deux fichiers, portent le coffre :
|
||||
|
||||
| Fichier | Contenu |
|
||||
| --- | --- |
|
||||
| `data/coffres.json` | Coffres (métadonnées + DEK chiffrées), consommateurs (hashes de jetons seulement), octrois, métadonnées des secrets. |
|
||||
| `data/coffres_valeurs.json` | Versions chiffrées des secrets (AES-256-GCM). |
|
||||
|
||||
Ces deux fichiers forment une **sauvegarde ordinaire chiffrée** :
|
||||
copiés seuls, ils ne donnent accès à aucune valeur — chaque DEK est
|
||||
chiffrée par la KEK, chaque valeur est chiffrée par la DEK de son
|
||||
coffre. Ils peuvent donc transiter par la sauvegarde habituelle du
|
||||
serveur, avec les mêmes permissions restrictives que sur l'instance
|
||||
(0600). Ils ne doivent jamais être déposés dans le dépôt Git, ni dans
|
||||
une note ObsiGate, ni dans un journal.
|
||||
|
||||
La **KEK ne fait pas partie de la sauvegarde** : elle est copiée à
|
||||
part, hors serveur (voir §2). Sauvegarder les deux fichiers et la KEK
|
||||
au même endroit annulerait la protection du chiffrement.
|
||||
|
||||
## 2. Sauvegarder la KEK (copie externe, hors serveur)
|
||||
|
||||
La KEK est une clé maître 256 bits (base64 de 32 octets), générée une
|
||||
seule fois via `POST /api/coffres/admin/generer-kek` (admin, coffres
|
||||
activés) et **jamais stockée par ObsiGate**. Au moment de sa
|
||||
génération :
|
||||
|
||||
1. Copiez-la immédiatement dans un gestionnaire de mots de passe ou
|
||||
sur un support hors ligne, hors du serveur ObsiGate.
|
||||
2. Ne la collez jamais dans le dépôt, jamais dans une note ObsiGate,
|
||||
jamais dans le même dossier que `data/`, jamais dans un journal.
|
||||
3. Vérifiez la copie (relisez-la depuis le gestionnaire, pas depuis
|
||||
l'écran qui ne l'affichera plus jamais).
|
||||
|
||||
Variables d'environnement (documentées aussi dans `.env.example`,
|
||||
sans aucune valeur) :
|
||||
|
||||
| Variable | Rôle | Défaut |
|
||||
| --- | --- | --- |
|
||||
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Toute route répond 503 tant qu'elle n'est pas activée. | désactivé |
|
||||
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). Prioritaire sur le fichier. | absente |
|
||||
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier 0600, hors dépôt et hors arborescence des vaults, contenant la KEK. Un fichier trop permissif est refusé. | absent |
|
||||
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut), donc des deux fichiers du coffre et de `api_keys.json`. | `data` |
|
||||
|
||||
**Aucune autre variable ne doit contenir une valeur de secret du
|
||||
coffre.** En particulier, ne créez jamais de variable du type
|
||||
`COFFRE_<nom>` avec une valeur : la seule valeur externe autorisée est
|
||||
la KEK, et elle n'est pas une valeur de secret du coffre.
|
||||
|
||||
## 3. Restauration sur une instance (y compris vierge)
|
||||
|
||||
Prérequis : une instance ObsiGate avec le coffre activé
|
||||
(`OBSIGATE_SECRETS_ENABLED`), et la **même KEK** que l'instance
|
||||
d'origine.
|
||||
|
||||
1. Arrêtez l'instance cible (ou, à défaut, assurez-vous qu'aucune
|
||||
écriture n'a lieu pendant la copie).
|
||||
2. Restaurez les deux fichiers `coffres.json` et
|
||||
`coffres_valeurs.json` dans le répertoire de données cible, en
|
||||
permissions 0600.
|
||||
3. Re-présentez la même KEK, via `OBSIGATE_KEK` ou via
|
||||
`OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt).
|
||||
4. Redémarrez l'instance.
|
||||
5. Vérifiez, sans jamais afficher ni journaliser de valeur :
|
||||
- la vue Secrets liste les coffres et les secrets avec
|
||||
`has_value = true` ;
|
||||
- une distribution de test via un consommateur existant (ou un
|
||||
consommateur de test, révoqué ensuite) aboutit — côté serveur,
|
||||
le succès seul est vérifié, la valeur n'est ni affichée ni
|
||||
consignée ;
|
||||
- le journal d'audit contient `distribution_ok` pour cette
|
||||
référence.
|
||||
|
||||
**Test de restauration obligatoire avant la mise en service réelle
|
||||
(issue #20)** : la procédure ci-dessus a été automatisée dans la
|
||||
suite de tests (`tests/test_coffre_secrets.py`, classe
|
||||
`TestRestaurationVierge`) — copie des deux fichiers vers un
|
||||
répertoire vierge, même KEK, distributions fonctionnelles — et doit
|
||||
aussi être rejouée une fois sur une instance réelle par l'opérateur
|
||||
avant de considérer la sauvegarde comme valide.
|
||||
|
||||
## 4. Perte de la KEK : perte d'accès assumée, par construction
|
||||
|
||||
Si la KEK est perdue, **il n'existe aucun moyen de récupérer les
|
||||
valeurs** : les DEK ne sont stockées que chiffrées par la KEK, et les
|
||||
valeurs ne sont stockées que chiffrées par les DEK (AES-256-GCM,
|
||||
authentifié). Il n'y a ni clé de secours, ni dérivation depuis le
|
||||
secret d'application, ni porte dérobée.
|
||||
|
||||
Procédure en cas de perte :
|
||||
|
||||
1. Considérez toutes les valeurs du coffre comme perdues.
|
||||
2. Créez de nouveaux coffres avec une nouvelle KEK.
|
||||
3. Pour chaque secret perdu, régénérez la valeur **côté fournisseur**
|
||||
(nouvelle clé API, nouveau jeton) et créez un nouveau secret avec
|
||||
cette nouvelle valeur ; révoquez les anciennes valeurs côté
|
||||
fournisseur, car elles restent valides hors d'ObsiGate tant qu'elles
|
||||
ne sont pas révoquées là-bas.
|
||||
4. Recréez les consommateurs et les octrois (les anciens octrois
|
||||
pointent vers des secrets qui n'existent plus dans les nouveaux
|
||||
coffres).
|
||||
5. Ne tentez pas de « réparer » les anciens fichiers avec une nouvelle
|
||||
KEK : le déchiffrement échouera (tag GCM invalide), ce qui est le
|
||||
comportement attendu, pas un bug.
|
||||
|
||||
La rotation KEK (`POST /api/coffres/admin/rotation-kek`) n'est pas
|
||||
une récupération : elle exige l'ancienne KEK encore valide pour
|
||||
re-chiffrer les DEK, puis le remplacement de la KEK dans
|
||||
l'environnement ou le fichier KEK.
|
||||
|
||||
## 5. Migration `api_keys.json` (Phase 2) — avertissement important
|
||||
|
||||
Le bouton « Importer l'existant » de la vue Secrets (admin seulement)
|
||||
copie les clés IA et clés d'outils de `data/api_keys.json` vers le
|
||||
coffre d'instance « ObsiGate interne » (slug `obsigate-interne`),
|
||||
en deux temps :
|
||||
|
||||
1. **Import** : les entrées avec valeur sont chiffrées dans le coffre
|
||||
(un secret déjà présent n'est jamais écrasé) ; `api_keys.json`
|
||||
n'est pas modifié.
|
||||
2. **Purge** : après confirmation explicite, et uniquement pour les
|
||||
noms migrés, les entrées sont supprimées du clair. Sans
|
||||
confirmation, rien n'est supprimé — jamais automatiquement.
|
||||
|
||||
**Avertissement** : les lecteurs actuels d'ObsiGate
|
||||
(`backend/ai.py`, `backend/tools/secrets.py`) lisent encore
|
||||
`api_keys.json` directement ; ils ne sont pas encore branchés sur le
|
||||
coffre. Purger le clair prive donc ces lecteurs de la clé tant que
|
||||
leur lecteur n'est pas branché sur une distribution depuis le coffre.
|
||||
Ne purgez que si vous avez vérifié que le consommateur concerné
|
||||
distribue depuis le coffre, ou si vous acceptez cette interruption.
|
||||
Ce branchement des lecteurs internes est un reste à faire explicite
|
||||
(voir §6).
|
||||
|
||||
## 6. Restes à faire (après cette Phase 2)
|
||||
|
||||
- Brancher les lecteurs internes (`ai.py`, `tools/secrets.py`) sur
|
||||
une distribution depuis le coffre d'instance, pour que la purge du
|
||||
clair ne coupe pas l'IA ni les outils.
|
||||
- Rejouer le test de restauration sur une instance réelle (la suite
|
||||
automatisée simule une instance vierge, elle ne remplace pas un
|
||||
essai sur l'instance de production).
|
||||
- Endpoint de distribution par session utilisateur (copie manuelle
|
||||
sans consommateur temporaire) : non ajouté ici, car il contredirait
|
||||
la règle Phase 1b (distribution réservée aux consommateurs) et
|
||||
exigerait une ré-authentification qui n'existe pas encore — décision
|
||||
à prendre avec l'opérateur avant toute implémentation.
|
||||
@@ -225,6 +225,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-115* | Éditeur Forge : cases à cocher collées au texte dans l'aperçu, aperçu qui flashe à la frappe, menu « dimensions du tableau » coupé hors écran sur un document court | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (CSS aperçu, `renderPreview`, `showTableForm`, `showLangPicker`) | Ouvrir un `.md` avec `- [ ]`, activer l'aperçu → texte collé au checkbox + flash « Rendering... » ; `/table` sur un document d'une ligne → forme partiellement visible | (1) règle `.task-list-item input[type=checkbox] { margin-right: 6px }` ajoutée au `<style>` de l'aperçu (miroir `style.css:2303`) ; (2) `renderPreview` ne remplace plus le contenu par le spinner (affiché seulement si vide) + flag `_previewDirty` pour rejouer la dernière frappe ; (3) `.inline-form`/`.lang-picker` en `position: fixed` (coordonnées viewport) avec clamp bas/droite comme le menu slash + `stopPropagation` sur les clics qi/slash (le listener document refermait la forme) | Vérifié en Playwright (instance E2E) : marginRight 6 px + `list-style: none`, MutationObserver : aucun « Rendering... » pendant 14 frappes et dernier état rendu, forme table `bottom 193 < vh 900` sur document 1 ligne + insertion OK |
|
||||
| *BUG-116* | Éditeur Forge : après insertion d'un tableau (ex. 4 lignes), la textarea reste d'une seule ligne au lieu de s'agrandir | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (`insertAtCursor`, `wrapSelection`) | Insérer un tableau via le menu : le contenu contient bien les nouvelles lignes mais la hauteur de la textarea ne change pas | Les mutations programmatiques de `ta.value` ne déclenchent pas l'événement `input` — seul handler qui rappelait `autoHeight()`/`updateLineNumbers()` (les handlers clavier, eux, les appelaient à la main). Ajout des deux appels dans `insertAtCursor` et `wrapSelection`, les deux points de passage de toutes les insertions (tableau, code, mermaid, checklist, gras multi-ligne) | Vérifié en Playwright sur l'instance E2E (qi → Table, 4 lignes, sur doc d'une ligne) : hauteur 51 → 189 px, 8 lignes rendues, gutter aligné | ### TODOs techniques (améliorations / nouvelles tâches)
|
||||
| *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 |
|
||||
|
||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
@@ -354,8 +356,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-10-14 | BUG-114 | Correction | `frontend/js/ui.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-114 — popup « currentPath is not defined » après suppression/renommage de répertoire.** Les branches répertoire de `confirmDeleteDirectory` (ligne ~1054) et du renommage inline (lignes ~967-968) référençaient `currentPath` nu au lieu de `state.currentPath` (ui.js ne déclare pas `currentPath`, contrairement à app.js) : `ReferenceError` levée dans le `try`, avalée par le `catch`, affichée en toast d'erreur alors que l'opération (DELETE/PATCH) avait réussi. Trois usages corrigés. Vérifié en Playwright sur l'instance E2E (flux réel clic droit → Supprimer) : toast de succès, 0 fantôme dans paths, 0 erreur JS ; validate-imports 42 modules / 373 exports, unit.test.mjs 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-14 | BUG-115 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-115 — trois ajustements de l'éditeur Forge (aperçu + menus).** (1) Cases à cocher collées au texte : le rendu serveur (mistune task_lists) ne met pas d'espace après l'`<input>` et l'`<style>` de `editor-poc.html` n'avait pas l'équivalent de la règle `.md-content .task-list-item` de `style.css` → deux règles ajoutées (margin-right 6 px, list-style none). (2) Flash de l'aperçu : `renderPreview` écrasait le contenu par « Rendering... » à chaque frappe (debounce 500 ms) — remplacé par un rafraîchissement silencieux (l'ancien rendu reste affiché, spinner seulement au premier rendu) + `_previewDirty` qui rejoue la dernière frappe tombée pendant un fetch (état fini correct, sans clignotement). (3) Menu « dimensions du tableau » coupé sur un document d'une ligne : `.inline-form`/`.lang-picker` en `position: absolute` recevaient des coordonnées **viewport** (`wr.left+pt.x`) → double décalage et clipping par `.editor-pane { overflow: hidden }` ; passés en `position: fixed` avec clamp viewport (convention du menu slash, déjà dans le fichier) ; au passage le clic sur un item qi/slash bullait jusqu'au listener document qui refermait la forme aussitôt ouverte → `stopPropagation`. Vérifié en Playwright sur l'instance E2E (serveur réel) : checkbox `marginRight: 6px`, aucun « Rendering... » observé pendant la frappe et dernier état rendu, forme table entièrement visible (`bottom 193 < vh 900`) et insertion OK. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-14 | BUG-116 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-116 — la textarea Forge ne grandissait pas après une insertion programmatique.** Après un tableau de 4 lignes inséré via le menu, l'éditeur restait d'une ligne : le contenu était bien multiligne mais `autoHeight()` n'était rappelé que par l'événement `input`, que `ta.value = …` ne déclenche pas. Les handlers clavier appelaient `autoHeight(); updateLineNumbers();` à la main ; `insertAtCursor` et `wrapSelection` — les deux fonctions par lesquelles passent toutes les insertions (tableau, bloc de code, templates mermaid, checklist, mise en forme) — oubliaient les deux appels. Corrigé à la source dans les deux fonctions plutôt que chez chaque appelant. Vérifié en Playwright (instance E2E, chemin réel qi → Table, 4 lignes sur document d'une ligne) : hauteur 51 → 189 px, gutter = nb de lignes | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-21 | BUG-118 | Correction | `frontend/index.html`, `frontend/draco-barre.css`, `tests/test_draco_barre.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-118 — la barre d'apps Fusion était inutilisable en mobile** (retours de Bruno sur captures téléphone). Deux causes : (1) le palier ≤ 700 px de la CSS commune gardait onglets entiers et pastilles domaines — la barre débordait ; la source dracodev-commons passe en **v0.1.2** (spec des barres, § Mobile : marque, onglet de l'app courante, alertes, Spark, avatar ; rail/recherche/domaines/menu masqués au profit des contrôles natifs). (2) Le bouton ☰ de la barre cliquait `sidebar-toggle-btn` (bureau) au lieu de `hamburger-btn` (tiroir mobile) : sur téléphone il ne faisait rien, doublé du vrai hamburger d'ObsiGate juste en dessous. Rail devenu contextuel (`matchMedia` ≤ 768 px, point de bascule d'ObsiGate). Test de non-régression `tests/test_draco_barre.py` (2 tests) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-15 | BUG-117 + #201 | Correction + feature | `frontend/js/dragdrop.js`, `frontend/js/ui.js`, `frontend/js/upload.js`, `frontend/locales/fr.json`, `frontend/locales/en.json`, `backend/services/mutations.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **BUG-117 — le drag & drop interne de la sidebar était mort en silence.** `onDrop` classait tout dépôt « externe » dès que `dataTransfer.items` était non vide — or un glisser interne dépose 4 formats string (`tree:*`) : chaque déplacement partait dans la branche upload où le filtre `kind === 'file'` ne trouvait rien — aucun appel API, aucun toast. Détection corrigée (vrais fichiers seulement, `dragState` prioritaire). Livré avec **#201 : menu contextuel Déplacer/Dupliquer** (fichiers et dossiers), modal `promptTargetFolder` réutilisé (+ `opts`, emplacement actuel pré-sélectionné), endpoint `POST /api/duplicate/{vault}` (`duplicate_path`, index incrémental, SSE/webhooks, refus inside-self), i18n FR/EN, 4 tests `TestDuplicateApi` + validation Playwright E2E complète | 🟢 corrigé/livré (en attente vérif utilisateur) |
|
||||
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+174
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.71.0 | **Dernière mise à jour :** 2026-10-20
|
||||
> **Version :** 2.81.4 | **Dernière mise à jour :** 2026-10-28
|
||||
> **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,160 @@
|
||||
|
||||
## 🔵 En cours
|
||||
|
||||
### 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)
|
||||
- **Fiche :** [features/fusion-panneau-version-agent-225.md](./features/fusion-panneau-version-agent-225.md)
|
||||
- **Description :** demande de Bruno (2026-10-26, carte fusion #64) : la
|
||||
version « Service d'agents vX », déjà visible dans la console pleine
|
||||
page, doit se lire aussi dans le **panneau natif des apps** (mode
|
||||
sidebar). Le pied du panneau Spark affiche désormais cette ligne,
|
||||
discrète et permanente, lue **en direct** dans la santé du service
|
||||
(`GET /healthz` de l'origine injectée par l'hôte) à l'ouverture du
|
||||
panneau, avec cache de session — jamais une valeur codée en dur ;
|
||||
service injoignable = rien d'affiché. Même livraison : TikTak
|
||||
0.61.3, Flowdeck 7.83.1, Kanbante 0.37.1.
|
||||
- **Sous-tâches :**
|
||||
- [x] Ligne de version dans le pied du panneau (canonique TikTak, propagée aux 4 apps)
|
||||
- [x] Test `tests/test_draco_barre.py::test_panneau_version_service_affichee_225` + épreuve comportementale jsdom sur les 4 copies
|
||||
- [x] Release publiée — CI verte à constater, redéploiement Docker (geste de Bruno)
|
||||
|
||||
### 224. Sécurité des dépendances — pip-audit vierge (brotli, filelock, opentelemetry-api, pypdf)
|
||||
|
||||
- **Effort :** ~0,1 jour | **Impact :** 🔴 | **Ouvert :** 2026-10-25 | **Statut :** ✅ livré le 2026-10-25 (version du hook — voir CHANGELOG ; redéploiement Docker requis)
|
||||
- **Fiche :** [features/securite-dependances-pip-audit-224.md](./features/securite-dependances-pip-audit-224.md)
|
||||
- **Description :** le job `security` du run CI 2303 (2.78.0) échoue
|
||||
sur `pip-audit` : 17 vulnérabilités dans 4 paquets hérités de
|
||||
l'environnement du runner (toolcache « already satisfied », même
|
||||
piège que BUG-093). Planchers explicites dans
|
||||
`backend/requirements.txt` : pypdf relevé à `>=6.21.2`, nouveaux
|
||||
planchers `brotli>=1.2.1`, `filelock>=4.2.1`,
|
||||
`opentelemetry-api>=1.45.1` ; garde-fou `TestDependencySecurityFloors`
|
||||
étendu. Aucun code applicatif, aucun nouvel ignore pip-audit.
|
||||
- **Sous-tâches :**
|
||||
- [x] Planchers de sécurité dans `backend/requirements.txt` + commentaires
|
||||
- [x] Garde-fou `tests/test_ci_workflow.py::TestDependencySecurityFloors` étendu (pypdf 6.21.2, brotli, filelock, opentelemetry-api)
|
||||
- [x] `pip-audit` local : 0 vulnérabilité non ignorée (mêmes ignores que la CI)
|
||||
- [x] Release publiée — CI verte à constater, redéploiement Docker (geste de Bruno)
|
||||
|
||||
### 223. Dev/prod sans noms publics en dur — URLs de l'écosystème par configuration
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-25 | **Statut :** ✅ livré le 2026-10-25 (version du hook — voir CHANGELOG ; redéploiement Docker requis, et en dev déclarer les noms en `-dev` dans le `.env` du poste)
|
||||
- **Fiche :** [features/devprod-urls-config-223.md](./features/devprod-urls-config-223.md)
|
||||
- **Description :** aucun nom public de l'écosystème dracodev ne doit être
|
||||
figé dans le code vivant : les URL publiques (service d'agents, autres
|
||||
apps) viennent de la configuration (défauts = noms **officiels** sans
|
||||
suffixe ; l'environnement dev déclare ses noms en `-dev` dans son
|
||||
`.env`). CSP dérivée de l'origine de l'agent configurée, panneau Spark
|
||||
et liens inter-apps alimentés par l'hôte (injection `window.DRACO_URLS`
|
||||
au service de `index.html`), références croisées reconstruites depuis
|
||||
la config à l'affichage (aucune donnée migrée).
|
||||
- **Sous-tâches :**
|
||||
- [x] Module `backend/draco_config.py` + `.env.example`
|
||||
- [x] CSP `connect-src`/`frame-src` dérivée de la config (tests via config)
|
||||
- [x] `external_refs` : hôtes reconnus + URL d'affichage construites par config
|
||||
- [x] Frontend : panneau Spark, liens TikTak, onglets des apps via `DRACO_URLS`
|
||||
- [x] WebAuthn : confirmation rp_id/origines dérivés de la requête / `OBSIGATE_WEBAUTHN_*`
|
||||
|
||||
### 222. Fusion — panneau Spark V3 : la console en surimpression pleine page
|
||||
|
||||
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-23 | **Statut :** ✅ livré le 2026-10-23 (2.77.0, redéploiement Docker en attente)
|
||||
- **Fiche :** [features/fusion-panneau-console-surimpression-222.md](./features/fusion-panneau-console-surimpression-222.md)
|
||||
- **Description :** le panneau Spark (asset vendangé `frontend/draco-panneau.js`,
|
||||
octet-identique dans les 4 apps) passe en V3 (demande de Bruno,
|
||||
2026-10-22) : le bouton « Console ⤢ » n'ouvre plus la console du
|
||||
service d'agents en nouvel onglet (#221) mais en **surimpression
|
||||
pleine page dans la même page** — montée à la largeur du panneau,
|
||||
étendue à toute la fenêtre par transition, iframe gardant le lien
|
||||
profond (`?disc=` / `?app=`), et deux sorties dans sa barre :
|
||||
« Mode sidebar » (rétraction vers le panneau) et « Fermer l'agent ».
|
||||
Styles injectés par le panneau ; côté service, la console autorise
|
||||
l'encadrement par les 4 apps (0.20.0). Même livraison : TikTak
|
||||
0.60.0, Flowdeck 7.81.0, Kanbante 0.29.0.
|
||||
- **Sous-tâches :**
|
||||
- [x] Surimpression + barre « Mode sidebar » / « Fermer l'agent »
|
||||
dans le panneau canonique (TikTak), recopié octet-identique
|
||||
dans les 4 apps
|
||||
- [x] Test `tests/test_draco_barre.py::test_panneau_console_surimpression_222`
|
||||
(et révision du test #221 : le nouvel onglet disparaît)
|
||||
- [x] Release v2.77.0 publiée — CI à constater, redéploiement Docker (geste de Bruno)
|
||||
|
||||
### 221. Fusion — panneau Spark : bouton « Console ↗ » vers la console du service
|
||||
|
||||
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.76.1, redéploiement Docker en attente)
|
||||
- **Fiche :** [features/fusion-panneau-console-221.md](./features/fusion-panneau-console-221.md)
|
||||
- **Description :** le panneau Spark (asset vendangé `frontend/draco-panneau.js`,
|
||||
octet-identique dans les 4 apps) gagne un bouton « Console ↗ » dans sa barre
|
||||
des discussions (demande de Bruno, 2026-10-22) : il ouvre la console web du
|
||||
service d'agents (https://agent.dracodev.net/) dans un nouvel onglet, sur la
|
||||
discussion actuellement affichée (`?disc=<id>`, URL construite au clic) et
|
||||
avec l'app d'origine (`?app=obsigate`, déduite du sous-domaine `og`). La
|
||||
console consomme déjà ces deux paramètres (lien profond de discussion +
|
||||
puce « Ouvert depuis »). Même livraison : TikTak 0.59.1, Flowdeck 7.80.1,
|
||||
Kanbante 0.28.1.
|
||||
- **Sous-tâches :**
|
||||
- [x] Bouton + `ouvrirConsole()` / `appHote()` dans le panneau canonique
|
||||
(TikTak), recopié octet-identique dans les 4 apps
|
||||
- [x] Test `tests/test_draco_barre.py::test_panneau_bouton_console_221`
|
||||
- [x] Release v2.76.1 publiée — CI à constater, redéploiement Docker (geste de Bruno)
|
||||
|
||||
### 220. Fusion — émission d'événements vers le journal du service d'agents (Lot 2, vague 5)
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.76.0, redéploiement Docker en attente)
|
||||
- **Fiche :** [features/fusion-journal-evenements-220.md](./features/fusion-journal-evenements-220.md)
|
||||
- **Description :** ObsiGate émet ses mutations de notes **explicites**
|
||||
vers le journal d'activité du service d'agents (contrat ACC-0.2,
|
||||
carte fusion-trois-apps #18) : `obsigate.note.created / edited /
|
||||
moved / deleted`, signées HMAC, en arrière-plan, inactives sans la
|
||||
clé `AGENT_EVENEMENTS_CLE`. Points d'accroche : les endpoints
|
||||
d'écriture de l'API web (acteur `user`) et la couche outils /
|
||||
serveur MCP (acteur `agent:mcp`). **Garde-fou : jamais le
|
||||
watcher** — seules les opérations explicites via l'API émettent.
|
||||
- **Sous-tâches :**
|
||||
- [x] Module `backend/agent_journal.py` (enveloppe ACC-0.2,
|
||||
signature HMAC-SHA256, envoi « au mieux »)
|
||||
- [x] Branchements `backend/routers/files_write.py` (créer,
|
||||
sauvegarder, renommer, déplacer, supprimer)
|
||||
- [x] Branchements `backend/tools/service.py` (create/edit/
|
||||
append/rename/move/delete, acteur selon le mode)
|
||||
- [x] Tests `tests/test_agent_journal.py` — dont la preuve du
|
||||
garde-fou watcher (écriture disque + indexation = silence)
|
||||
|
||||
### 218. Fusion — panneau Spark intégré (agent commun)
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.74.0, redéploiement Docker en attente)
|
||||
- **Fiche :** [features/fusion-panneau-spark-218.md](./features/fusion-panneau-spark-218.md)
|
||||
- **Description :** le bouton Spark de la barre commune (#215) ouvre le
|
||||
panneau de l'agent commun (service d'agents dracodev : conversation,
|
||||
Aujourd'hui, approbations, missions longues), comme dans TikTak,
|
||||
Flowdeck et Kanbante. Assets partagés vendangés à l'identique ;
|
||||
la CSP nomme le service en `connect-src`.
|
||||
- **Sous-tâches :**
|
||||
- [x] Assets `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
|
||||
vendangés depuis le panneau partagé du programme
|
||||
- [x] `frontend/index.html` : link CSS + script, action `spark` →
|
||||
`DRACO_PANNEAU.toggle()` (repli honnête si le panneau ne charge pas)
|
||||
- [x] CSP `connect-src` : `https://agent.dracodev.net` ajouté
|
||||
(`backend/main.py`, seul hôte ajouté)
|
||||
- [x] Tests `test_draco_barre.py` (intégration + CSP verrouillées)
|
||||
|
||||
### 219. Fusion — panneau Spark v2 (Lot 1)
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.75.0, redéploiement Docker en attente)
|
||||
- **Fiche :** [features/fusion-panneau-spark-v2-219.md](./features/fusion-panneau-spark-v2-219.md)
|
||||
- **Description :** le panneau Spark (#218) passe en v2, à l'identique
|
||||
dans les quatre applications (carte fusion-trois-apps #17) :
|
||||
réponses de l'agent en **Markdown rendu** (HTML produit par le
|
||||
service d'agents 0.10.0, sanitizé côté serveur), **largeur
|
||||
réglable** persistée, **thème clair/sombre aligné** sur
|
||||
l'application hôte, **discussions façon Muse** (principale
|
||||
perpétuelle + parallèles créées/archivées/supprimées). Côté
|
||||
ObsiGate : assets vendangés uniquement, aucun code propre touché.
|
||||
- **Sous-tâches :**
|
||||
- [x] Assets `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
|
||||
remplacés par la v2 partagée du programme
|
||||
- [x] Fiche de conception `features/fusion-panneau-spark-v2-219.md`
|
||||
|
||||
### 215. Fusion bloc 1 — barre d'apps commune et références croisées des notes
|
||||
|
||||
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-20 | **Statut :** ✅ livré le 2026-10-20 (2.70.0)
|
||||
@@ -79,6 +233,22 @@
|
||||
re-validé côté frontend (`auth.js`)
|
||||
- [x] Tag OpenAPI « SSO », tests `test_draco_session.py` (22)
|
||||
|
||||
### 217. Fusion ACC-3.2 — validation locale du jeton `draco_session`
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.72.0)
|
||||
- **Fiche :** [features/fusion-acc32-validation-locale-217.md](./features/fusion-acc32-validation-locale-217.md)
|
||||
- **Description :** pendant consommateur de #216 — `get_current_user`
|
||||
accepte le cookie partagé à défaut de jeton natif valide ; le `sub`
|
||||
étant l'identifiant ObsiGate, la correspondance vers le compte local
|
||||
est directe (compte actif exigé, garde-fou BUG-028 appliqué).
|
||||
Issue Gitea ObsiGate #9 ; épopée Projets/fusion-trois-apps#7.
|
||||
- **Sous-tâches :**
|
||||
- [x] `draco_session.user_for_draco()` : résolution par id, `_draco_sub`
|
||||
- [x] Repli `_draco_fallback()` sur toutes les sorties « non
|
||||
authentifié » du chemin natif (`backend/auth/middleware.py`)
|
||||
- [x] Tests `test_draco_session.py` (+4 : cookie seul, révoqué,
|
||||
sub inconnu, inerte sans secret)
|
||||
|
||||
### 196. Partage dirigé entre utilisateurs (document → utilisateur)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
|
||||
@@ -996,6 +1166,9 @@
|
||||
| 166 | Assistant IA — détection & fusion de doublons (score déterministe, paires candidates, fusion backup + confirmation) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
||||
| 168 | Assistant IA — notifications externes Discord / Telegram / SMTP / webhook (déclencheurs, secrets hors config, SSRF-safe) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
||||
| 170 | Assistant IA — tâches planifiées type cron (create/append/notify, interval/daily/once, tick 60 s, échec notifié) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
|
||||
| BUG-119 | Panneau Spark — « mode console » bloqué par la CSP (`frame-src` sans l'origine du service d'agents) | 2.77.1 | [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) |
|
||||
| BUG-120 | Menu inter-apps — les URL sœurs (`window.DRACO_URLS`) dérivent l'environnement de l'hôte de l'agent (`-dev` en dev), explicite prioritaire, Gitea inchangée (suite de #223) | 2.78.2 | [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) |
|
||||
| 225 | Panneau Spark — version du service d'agents visible en mode sidebar (pied du panneau, lue en direct via `GET /healthz`) | 2.79.0 | [features/fusion-panneau-version-agent-225.md](./features/fusion-panneau-version-agent-225.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# #223 — Dev/prod sans noms publics en dur : URLs de l'écosystème par configuration
|
||||
|
||||
> Ouvert le 2026-10-25 (chantier dev/prod de Bruno). Contexte : l'installation
|
||||
> actuelle (`deepin.lab.home`) devient l'environnement **dev** (noms en `-dev`),
|
||||
> la future installation (`jump.point.mint.home`) sera la **prod** (noms
|
||||
> officiels sans suffixe), identique sauf noms et clés. Règle : aucun nom
|
||||
> public de l'écosystème codé en dur dans le code vivant ; défauts = noms
|
||||
> officiels ; le dev déclare ses `-dev` dans son seul `.env`.
|
||||
|
||||
## Configuration (nouveau module `backend/draco_config.py`)
|
||||
|
||||
Lecture à l'appel, jamais à l'import. Défauts officiels :
|
||||
|
||||
| Variable | Défaut officiel | Rôle |
|
||||
|---|---|---|
|
||||
| `DRACO_AGENT_URL` | `https://agent.dracodev.net` | Base du service d'agents. À défaut : origine dérivée d'`AGENT_EVENEMENTS_URL`, puis défaut. |
|
||||
| `DRACO_OBSIGATE_URL` | `https://og.dracodev.net` | Liens profonds ObsiGate |
|
||||
| `DRACO_FLOWDECK_URL` | `https://flowdeck.dracodev.net` | Liens profonds Flowdeck |
|
||||
| `DRACO_TIKTAK_URL` | `https://tiktak.dracodev.net` | Liens profonds TikTak |
|
||||
| `DRACO_KANBANTE_URL` | `https://kanbante.dracodev.net` | Onglet Kanbante de la barre |
|
||||
| `DRACO_GITEA_URL` | `https://git.dracodev.net` | Cartes Kanbante (= issues Gitea). Distincte d'`OBSIGATE_GITEA_URL` (source connectée de l'assistant). |
|
||||
|
||||
`AGENT_EVENEMENTS_URL` (existant, #220) reste la configuration première du
|
||||
journal d'événements ; `agent_journal._url()` retombe sur
|
||||
`DRACO_AGENT_URL + /api/v1/evenements` quand elle est absente.
|
||||
|
||||
## Backend
|
||||
|
||||
- **CSP** (`backend/main.py`, `SecurityHeadersMiddleware`) : `connect-src` et
|
||||
`frame-src` utilisent `draco_config.agent_origine()` — dérivée de la config
|
||||
à chaque réponse, plus aucun littéral. Tests dans
|
||||
`tests/test_security_headers.py` **via la config** (défaut officiel neutralisé,
|
||||
dérivation depuis `AGENT_EVENEMENTS_URL`, priorité `DRACO_AGENT_URL`).
|
||||
- **Références croisées** (`backend/external_refs.py`) :
|
||||
- `parse_deep_link` reconnaît les hôtes officiels **et** configurés
|
||||
(`draco_config.hotes_reconnus()`) — un lien collé depuis le dev comme
|
||||
depuis la prod est reconnu.
|
||||
- `build_deep_link(app, type, id)` construit l'URL d'affichage depuis la
|
||||
config ; `list_refs` / `get_ref` / `delete_ref` servent des copies dont
|
||||
`target_url` est **reconstruite à la lecture**. ⚠️ Les références
|
||||
**stockées** (`data/external_refs/*.json`) gardent l'URL absolue collée
|
||||
par l'utilisateur : aucune migration, la valeur stockée n'est simplement
|
||||
plus jamais servie telle quelle.
|
||||
- **WebAuthn — confirmé, aucun changement** : `resolve_relying_party()`
|
||||
(`backend/auth/webauthn_mfa.py`, BUG-070) dérive `rp_id`/origines de la
|
||||
requête (hôte exact, `X-Forwarded-Host`/`X-Forwarded-Proto` honorés si
|
||||
`OBSIGATE_TRUST_PROXY=true`), et `OBSIGATE_WEBAUTHN_RP_ID` /
|
||||
`OBSIGATE_WEBAUTHN_ORIGINS` priment quand ils sont posés. Aucun nom figé.
|
||||
|
||||
## Frontend (contrat d'injection de dracodev-commons)
|
||||
|
||||
L'hôte fournit les URL : `backend/main.py::_index_html()` injecte
|
||||
`window.DRACO_URLS = {…}` (JSON issu de `draco_config.urls_publiques()`,
|
||||
nonce CSP par le mécanisme existant) dans `index.html` au service.
|
||||
|
||||
- `frontend/draco-panneau.js` : `API = window.DRACO_URLS.agent || défaut officiel` ;
|
||||
`appHote()` retire un suffixe `-dev` éventuel du premier label d'hôte
|
||||
(`og-dev` → `obsigate` pour le lien profond `?app=`).
|
||||
- `frontend/draco-liens.js` : « Ouvrir dans TikTak » =
|
||||
`window.DRACO_URLS.tiktak || défaut officiel`.
|
||||
- `frontend/index.html` : les `href` des onglets portent les défauts officiels ;
|
||||
le script de configuration de la barre les réécrit depuis `DRACO_URLS`.
|
||||
Le menu des apps (`draco-barre.js`) est construit depuis ces onglets : il
|
||||
suit donc sans changement.
|
||||
- Chemin réellement servi : `/static/draco-*.js` via le montage `StaticFiles`,
|
||||
`Cache-Control: no-cache` posé par `SecurityHeadersMiddleware` sur
|
||||
`/static/*`, et service worker **network-first** pour le code — aucun
|
||||
cache-busting par empreinte n'existe (revalidation systématique).
|
||||
|
||||
## Correctif BUG-120 (2026-10-25) — dérivation d'environnement des sœurs
|
||||
|
||||
Constat sur la dev : le `.env` du poste ne déclare que l'agent (via
|
||||
`AGENT_EVENEMENTS_URL`) ; les quatre `DRACO_<APP>_URL` absentes
|
||||
retombaient sur les défauts prod dans `window.DRACO_URLS` — le menu
|
||||
inter-apps pointait la prod depuis la dev. La résolution d'une URL
|
||||
sœur (`backend/draco_config.py::app_url`) gagne une étape
|
||||
intermédiaire, entre l'explicite et le défaut officiel :
|
||||
**dérivation depuis l'hôte de l'agent** (`agent_url()`) — hôte de la
|
||||
forme `agent<SUFFIXE>.dracodev.net` sans port → sœur =
|
||||
`https://<slug><SUFFIXE>.dracodev.net` (slugs : `og`, `flowdeck`,
|
||||
`tiktak`, `kanbante`). Hôte hors motif (autre domaine, LAN, IP,
|
||||
port) → aucune dérivation. Gitea exclue. Le dev n'a plus quatre
|
||||
variables à déclarer ; un `.env` qui les déclare déjà est inchangé
|
||||
(l'explicite prime).
|
||||
|
||||
## Restes assumés
|
||||
|
||||
- Suffixe SSO `dracodev.net` (`draco_session.validate_return_to`,
|
||||
`frontend/js/auth.js`) : ce n'est pas un nom d'hôte mais le domaine parent
|
||||
commun aux deux environnements (les noms `-dev` restent des sous-domaines) ;
|
||||
le domaine du cookie est déjà configurable (`DRACO_SESSION_COOKIE_DOMAIN`).
|
||||
- Mentions textuelles (titres/messages du panneau, contact OpenAPI et lien
|
||||
« À propos » vers le dépôt Git) : infra Git hors périmètre, ou libellés.
|
||||
- `OBSIGATE_SEARXNG_URL` : déjà configurable, défaut officiel conservé.
|
||||
- Le panneau reste un asset partagé : la même ligne d'injection doit être
|
||||
recopiée dans les 3 autres apps par leurs chantiers respectifs pour
|
||||
retrouver l'octet-identique.
|
||||
@@ -0,0 +1,51 @@
|
||||
# #217 — Validation locale du jeton draco_session (Fusion ACC-3.2)
|
||||
|
||||
Statut : ✅ livré le 2026-10-21 (2.72.0)
|
||||
|
||||
## Contexte
|
||||
|
||||
Suite de l'émettrice SSO (#216, ACC-3.1). Ici, ObsiGate joue son rôle
|
||||
de **consommatrice** : `get_current_user` accepte le cookie partagé
|
||||
`draco_session` à défaut de jeton natif valide. C'est la brique qui
|
||||
servira aussi à l'API de l'agent commun (ACC-4) pour authentifier les
|
||||
requêtes SSO. Les validateurs des autres applications sont leurs
|
||||
tickets respectifs (Flowdeck #21, TikTak #213 — TikTak 0.52.0).
|
||||
|
||||
## Correspondance sub → compte local
|
||||
|
||||
ObsiGate est émettrice ET consommatrice : le `sub` du jeton **est**
|
||||
son identifiant d'utilisateur — la résolution est directe, par `id`,
|
||||
dans `draco_session.user_for_draco()` :
|
||||
|
||||
- signature/expiration/révocation vérifiées par `verify_token` (#216) ;
|
||||
- compte **actif** exigé ;
|
||||
- garde-fou BUG-028 appliqué à l'identique : un jeton draco émis
|
||||
avant le dernier changement de mot de passe (`password_changed_at`)
|
||||
est refusé ;
|
||||
- le dict utilisateur retourné porte `_token_vaults` (droits du
|
||||
compte), `_token_scope = "full"` et `_draco_sub` (traçabilité).
|
||||
|
||||
## Branchement
|
||||
|
||||
Dans `backend/auth/middleware.py`, chaque sortie « non authentifié »
|
||||
du chemin natif (aucun jeton, jeton illisible, jti natif révoqué,
|
||||
compte introuvable/inactif, jeton antérieur au changement de mot de
|
||||
passe) passe par `_draco_fallback()`. Sûreté du repli après
|
||||
déconnexion globale : le logout révoque aussi le `jti` draco et
|
||||
efface le cookie (#216), donc un ancien cookie rejoué est refusé par
|
||||
`verify_token`.
|
||||
|
||||
## Limite connue
|
||||
|
||||
Le repli authentifie les **appels d'API**. Le bootstrap d'une
|
||||
session complète du SPA (jetons natifs émis à partir du seul cookie
|
||||
draco) n'est pas couvert : pour ObsiGate, émettrice, la connexion
|
||||
native EST la connexion SSO — le cas d'usage du repli est la
|
||||
continuité d'API quand le jeton natif a expiré avant le cookie
|
||||
partagé, et les consommateurs machine de l'API.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_draco_session.py` (+4) : `/api/auth/me` avec le seul
|
||||
cookie draco (200, bon utilisateur), jti révoqué refusé, sub inconnu
|
||||
refusé, inactivité sans secret configuré.
|
||||
@@ -0,0 +1,98 @@
|
||||
# #220 — Fusion : émission d'événements vers le journal du service d'agents
|
||||
|
||||
**Contexte.** Le Lot 2 du programme Fusion (carte fusion-trois-apps
|
||||
#18) dote le service d'agents d'un **journal d'activité** cross-apps :
|
||||
chaque application y émet ses mutations significatives au contrat
|
||||
**ACC-0.2** — enveloppe `{id, type, occurred_at, actor, domain,
|
||||
source_app, object{ref, label, url}}` signée **HMAC-SHA256** du corps
|
||||
brut (en-tête `X-Draco-Signature`, clé propre à chaque application),
|
||||
que le service expose dans la vue Journal de sa console (agent-commun
|
||||
0.11.0). TikTak (0.59.0) et Kanbante (0.28.0) émettent déjà ; ObsiGate
|
||||
est la troisième source, après Flowdeck.
|
||||
|
||||
**Principe directeur : référence, jamais contenu.** L'enveloppe ne
|
||||
contient que la référence de la note (`fichier:<vault>/<chemin>`),
|
||||
son nom (libellé) et un lien profond (`/#file=<vault>:<chemin>`) —
|
||||
jamais son contenu.
|
||||
|
||||
## Garde-fou central : jamais le watcher
|
||||
|
||||
ObsiGate surveille les vaults par un **watcher** de système de
|
||||
fichiers (`backend/watcher.py`) qui réindexe chaque changement
|
||||
disque — le vault principal compte **222 393 fichiers**. Brancher
|
||||
l'émission sur le watcher inonderait le journal (et confondrait les
|
||||
gestes d'Obsidian, de la synchro de fichiers ou d'un éditeur externe
|
||||
avec des opérations faites *dans* ObsiGate).
|
||||
|
||||
L'émission n'a donc lieu que pour les **opérations explicites via
|
||||
l'API web**, à deux points d'accroche, et nulle part ailleurs :
|
||||
|
||||
| Source | Point d'accroche | Acteur |
|
||||
|---|---|---|
|
||||
| Interface web / clients REST | `backend/routers/files_write.py` | `user` |
|
||||
| Serveur MCP d'ObsiGate (outils) | `backend/tools/service.py`, mode `mcp` | `agent:mcp` |
|
||||
| Assistant intégré (outils, mode `in_app`) | `backend/tools/service.py` | `user` |
|
||||
|
||||
Conséquences assumées :
|
||||
|
||||
- une note créée/modifiée **hors API** (Obsidian, éditeur externe,
|
||||
copie de fichiers) n'apparaît **pas** au journal — c'est voulu ;
|
||||
- les écritures internes qui passent par la couche mutations sans
|
||||
passer par ces points (planificateur, crawler, fusion de doublons,
|
||||
restauration de sauvegarde) n'émettent pas non plus ;
|
||||
- le test `test_watcher_et_indexation_sans_evenement` verrouille le
|
||||
garde-fou : écriture disque directe + gestionnaires du watcher +
|
||||
`update_single_file` de l'indexeur → **zéro émission**.
|
||||
|
||||
## Catalogue d'événements émis
|
||||
|
||||
| Type | Déclencheur |
|
||||
|---|---|
|
||||
| `obsigate.note.created` | `POST /api/file/{vault}` ; outil `create_file` |
|
||||
| `obsigate.note.edited` | `PUT /api/file/{vault}/save` (+ variantes xlsx/csv : cellules, structure, style) ; outils `edit_file`, `append_to_file` |
|
||||
| `obsigate.note.moved` | `PATCH /api/file/{vault}` (renommage), `POST /api/move/{vault}` (**fichiers seulement**) ; outils `rename_file`, `move_path` (fichiers seulement) — la référence pointe sur le **nouveau** chemin |
|
||||
| `obsigate.note.deleted` | `DELETE /api/file/{vault}` ; outil `delete_file` |
|
||||
|
||||
Hors catalogue (non émis, volontairement) : dossiers (création,
|
||||
renommage, déplacement, suppression — le catalogue est `note.*`),
|
||||
téléversement par lots (`batch-upload`), duplication, restauration
|
||||
de sauvegarde, `replace_in_files` (multi-fichiers).
|
||||
|
||||
## Module `backend/agent_journal.py`
|
||||
|
||||
- **Activation** : `AGENT_EVENEMENTS_CLE` (clé propre à ObsiGate,
|
||||
jamais en dur). Sans clé, le module est **inactif** : aucun appel,
|
||||
aucun bruit. URL surchargée par `AGENT_EVENEMENTS_URL` (défaut
|
||||
`https://agent.dracodev.net/api/v1/evenements`).
|
||||
- **Discipline « au mieux »** (identique à TikTak/Kanbante) : envoi
|
||||
dans un fil d'arrière-plan démon via httpx, délai maximal 2 s,
|
||||
exceptions avalées avec une ligne de journal local — une panne du
|
||||
service d'agents ne casse ni ne ralentit jamais le métier (prouvé
|
||||
par `test_recepteur_en_panne_ne_casse_rien`).
|
||||
- **Domaine** : ObsiGate ne modélise pas encore le domaine d'un
|
||||
vault (la question Q-A du programme — les domaines sont-ils des
|
||||
vaults distincts ? — est toujours ouverte). La correspondance peut
|
||||
être déclarée par `AGENT_EVENEMENTS_DOMAINES` (JSON
|
||||
`{"NomDuVault": "famille"}`) ; repli `pro`, qui est aussi le
|
||||
défaut du journal du service.
|
||||
|
||||
## Activation en production (gestes de Bruno)
|
||||
|
||||
1. Générer une clé propre à ObsiGate (`openssl rand -hex 32`).
|
||||
2. Service d'agents : ajouter l'entrée `obsigate` à
|
||||
`AGENT_EVENEMENTS_CLES` (JSON des clés par application), recréer
|
||||
le conteneur.
|
||||
3. ObsiGate : poser `AGENT_EVENEMENTS_CLE=<même valeur>` dans son
|
||||
environnement et **redéployer** (Docker).
|
||||
|
||||
Tant que la clé n'est pas posée des deux côtés, ObsiGate n'émet
|
||||
rien — c'est le comportement voulu.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_agent_journal.py` (7 tests) : enveloppe et signature
|
||||
revérifiées comme le fait le service ; cycle de vie complet en REST
|
||||
(créer → éditer → renommer → déplacer → supprimer, acteur `user`) ;
|
||||
chemin outils/MCP (`create/edit/rename/delete`, acteur
|
||||
`agent:mcp`) ; inactivité sans clé ; garde-fou watcher/indexation ;
|
||||
panne du récepteur sans effet métier ; correspondance de domaines.
|
||||
@@ -0,0 +1,42 @@
|
||||
# #221 — Fusion : bouton « Console ↗ » dans le panneau Spark
|
||||
|
||||
**Contexte.** Le panneau Spark (#218) et la console web du service
|
||||
d'agents (Lot 2, https://agent.dracodev.net/) sont deux faces du même
|
||||
service : mêmes discussions, mêmes approbations, mêmes missions. Il
|
||||
manquait le lien de navigation entre les deux. Demande de Bruno
|
||||
(2026-10-22) : un bouton dans le panneau qui ouvre la console.
|
||||
|
||||
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
|
||||
maintenu en canonique dans TikTak et recopié **octet-identique** dans
|
||||
les 4 applications (TikTak, Flowdeck, Kanbante, ObsiGate) :
|
||||
|
||||
- un bouton fantôme « Console ↗ » dans la barre des discussions, à
|
||||
côté de « ☰ Discussions » et « + Nouvelle » (même classe `d-btn`,
|
||||
donc visible en thème clair comme sombre, le panneau suivant le
|
||||
thème de l'app hôte) ;
|
||||
- au clic, `ouvrirConsole()` construit l'URL **à ce moment-là** (la
|
||||
discussion peut avoir changé sans recharger le panneau) :
|
||||
`https://agent.dracodev.net/?disc=<id de la discussion affichée>&app=<app hôte>` ;
|
||||
- la discussion vient de l'état du panneau (`convId`, repli
|
||||
`sessionStorage draco_panneau_conv`) ; l'app hôte est déduite du
|
||||
sous-domaine par `appHote()` (`og` → `obsigate`, `tiktak`,
|
||||
`flowdeck`, `kanbante` ; hôte inconnu → pas de paramètre) ;
|
||||
- ouverture en **nouvel onglet** (`window.open(..., "_blank",
|
||||
"noopener")`). Sans discussion connue : lien simple vers la
|
||||
racine de la console.
|
||||
|
||||
La console consomme déjà les deux paramètres : `?disc=` ouvre la
|
||||
discussion demandée (lien profond), `?app=` s'affiche en puce
|
||||
« Ouvert depuis : … » dans « Ce que Spark voit ».
|
||||
|
||||
**Vérifications.** Syntaxe JS (`node --check`), test fonctionnel de
|
||||
la construction d'URL (4 cas : app connue/inconnue × discussion
|
||||
présente/absente), test de non-régression
|
||||
`tests/test_draco_barre.py::test_panneau_bouton_console_221`
|
||||
(marqueurs du bouton et de la logique dans l'asset servi), somme
|
||||
md5 commune des 4 copies : `c67cc2ab8e657b4e545fa93b28c3a0c4`.
|
||||
|
||||
**Versions.** ObsiGate 2.76.1 (cette livraison) ; TikTak 0.59.1 et
|
||||
Flowdeck 7.80.1 déployées automatiquement sur release ; Kanbante
|
||||
0.28.1 publiée. **Activation côté ObsiGate : redéploiement Docker
|
||||
par Bruno.**
|
||||
@@ -0,0 +1,52 @@
|
||||
# #222 — Fusion : panneau Spark V3, la console en surimpression pleine page
|
||||
|
||||
**Contexte.** Le bouton « Console ↗ » du panneau Spark (#221) ouvrait
|
||||
la console web du service d'agents (https://agent.dracodev.net/)
|
||||
dans un nouvel onglet : on quittait l'application en cours pour un
|
||||
autre onglet. Demande de Bruno (2026-10-22) : rester dans la même
|
||||
page — la console s'ouvre par-dessus l'application, avec une
|
||||
animation comme celle de la barre latérale mais qui recouvre la
|
||||
page au complet, et deux sorties explicites : revenir en mode
|
||||
sidebar, ou fermer l'agent.
|
||||
|
||||
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
|
||||
maintenu en canonique dans TikTak et recopié **octet-identique** dans
|
||||
les 4 applications (TikTak, Flowdeck, Kanbante, ObsiGate) :
|
||||
|
||||
- le bouton devient « Console ⤢ » (même emplacement, barre des
|
||||
discussions) et `ouvrirConsole()` n'ouvre plus d'onglet : il
|
||||
monte une **surimpression** `.draco-console-sur` ancrée à droite,
|
||||
née à la largeur du panneau, qui **s'étend à 100 % de la fenêtre**
|
||||
par transition CSS (double `requestAnimationFrame` pour laisser
|
||||
peindre la géométrie de départ) ;
|
||||
- la console est chargée dans une **iframe** dont l'URL reste
|
||||
construite au premier clic par `urlConsole()` : discussion
|
||||
affichée (`?disc=<id>`) et app d'origine (`?app=obsigate`, déduite
|
||||
du sous-domaine `og`) — le lien profond de #221 est conservé ;
|
||||
- la barre de la surimpression (« ✦ Console Spark ») porte les deux
|
||||
sorties demandées : **« ◧ Mode sidebar »** (`retrecirConsole()` :
|
||||
l'overlay se rétracte à la largeur du panneau puis se masque,
|
||||
l'iframe reste chargée) et **« ✕ Fermer l'agent »** (`fermer()` +
|
||||
rétraction : panneau et console fermés) ;
|
||||
- les styles de la surimpression sont **injectés par le panneau
|
||||
lui-même** (`<style id="draco-console-styles">`, variables du
|
||||
thème du panneau) : aucun fichier CSS à toucher côté hôte, et la
|
||||
surimpression suit le thème de l'app comme le panneau.
|
||||
|
||||
Côté service d'agents (0.20.0) : la console autorise désormais
|
||||
l'encadrement par les 4 applications (`frame-ancestors` restreint
|
||||
aux sous-domaines `dracodev.net` des apps) — sans quoi l'iframe
|
||||
resterait blanche.
|
||||
|
||||
**Vérifications.** Syntaxe JS (`node --check`) ; test
|
||||
`tests/test_draco_barre.py::test_panneau_console_surimpression_222`
|
||||
(marqueurs de la surimpression, des deux sorties et de l'animation
|
||||
dans l'asset) ; le test #221 est révisé en conséquence (le lien
|
||||
profond `?disc=` / `?app=` subsiste, l'ouverture en nouvel onglet
|
||||
disparaît) ; somme md5 commune des copies :
|
||||
`93026ffa14bc8ee896349e3858e12edf`.
|
||||
|
||||
**Versions.** ObsiGate 2.77.0 (cette livraison) ; TikTak 0.60.0 et
|
||||
Flowdeck 7.81.0 déployées automatiquement sur release ; Kanbante
|
||||
0.29.0 publiée. **Activation côté ObsiGate : redéploiement Docker
|
||||
par Bruno.**
|
||||
@@ -0,0 +1,38 @@
|
||||
# #218 — Fusion : panneau Spark intégré (agent commun)
|
||||
|
||||
**Contexte.** Le programme Fusion a livré le service d'agents commun
|
||||
(agent-commun : conversation LLM, lectures/écritures des quatre
|
||||
applications via approbations, sous-agents, missions longues). Trois
|
||||
applications affichaient déjà son panneau partagé (TikTak, Flowdeck,
|
||||
Kanbante) ; ObsiGate, pourtant émettrice de l'identité `draco_session`,
|
||||
gardait le message d'attente « arrive en phase 4 » sur le bouton Spark
|
||||
de sa barre commune (#215).
|
||||
|
||||
**Livraison.**
|
||||
|
||||
- Assets vendangés à l'identique des autres applications :
|
||||
`frontend/draco-panneau.js` et `frontend/draco-panneau.css`, à la
|
||||
racine de `frontend/` comme les autres assets draco (servis par le
|
||||
montage `/static`, `Cache-Control: no-cache` déjà en place — aucun
|
||||
mécanisme de versionnage à ajouter, `index.html` n'est pas un
|
||||
gabarit Jinja).
|
||||
- `frontend/index.html` : lien CSS après celui de la barre, script
|
||||
différé après `draco-liens.js`, action `spark` de
|
||||
`DRACO_BAR_CONFIG.onAction` branchée sur
|
||||
`window.DRACO_PANNEAU.toggle()` avec repli explicite si l'asset ne
|
||||
charge pas.
|
||||
- CSP (`backend/main.py`, `SecurityHeadersMiddleware`) :
|
||||
`https://agent.dracodev.net` ajouté à `connect-src` — le panneau
|
||||
appelle le service en `fetch` avec le cookie de session ; aucun
|
||||
autre hôte n'est ouvert (précédent : Flowdeck, même exception).
|
||||
|
||||
**Vérifications.** Tests `tests/test_draco_barre.py` étendus
|
||||
(présence et références des assets, branche spark, hôte CSP) ;
|
||||
vérification visuelle et E2E à confirmer après redéploiement Docker
|
||||
(le panneau, `z-index: 400`, cohabite avec des surfaces ObsiGate à
|
||||
z-index plus élevé — à observer en réel).
|
||||
|
||||
**Hors périmètre.** Les textes du panneau vivent dans l'asset partagé
|
||||
du programme (français, identique dans les quatre applications) ; ils
|
||||
ne passent pas par les locales FR/EN d'ObsiGate — écart assumé au
|
||||
niveau du programme, pas de cette fiche.
|
||||
@@ -0,0 +1,29 @@
|
||||
# #219 — Fusion : panneau Spark v2 (Lot 1)
|
||||
|
||||
**Contexte.** Le panneau Spark livré en 2.74.0 (#218) affichait les
|
||||
réponses de l'agent en texte brut (les `##` et `**` du Markdown
|
||||
restaient visibles), avait une largeur fixe, un thème clair codé en
|
||||
dur déconnecté de l'application hôte, et une seule conversation sans
|
||||
gestion. Le Lot 1 du « Panneau v2 » (carte fusion-trois-apps #17,
|
||||
arbitrage de Bruno du 2026-10-22) corrige ces quatre points, à
|
||||
l'identique dans les quatre applications du programme.
|
||||
|
||||
**Livraison (côté ObsiGate : assets vendangés uniquement).**
|
||||
|
||||
- `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
|
||||
remplacés par la v2 partagée (octet pour octet avec TikTak,
|
||||
Flowdeck et Kanbante) :
|
||||
- **Markdown rendu** : les réponses et les rapports de missions
|
||||
affichent le HTML produit par le service d'agents (0.10.0 :
|
||||
mistune + sanitizer côté serveur) ;
|
||||
- **largeur réglable** à la souris (poignée, persistée,
|
||||
double-clic = largeur par défaut) ;
|
||||
- **thème clair/sombre aligné** sur l'application hôte
|
||||
(`html[data-theme]`, ici celui d'ObsiGate, suivi en direct) ;
|
||||
- **discussions façon Muse** : discussion principale
|
||||
perpétuelle + discussions parallèles créées, archivées et
|
||||
supprimées depuis le tiroir du panneau.
|
||||
- Le travail de fond est dans le service d'agents (agent-commun
|
||||
0.10.0) ; ObsiGate ne change aucun code propre. Le `/static` est
|
||||
déjà servi en `Cache-Control: no-cache` : aucun versionnage
|
||||
d'asset à ajouter. Redéploiement Docker requis.
|
||||
@@ -0,0 +1,40 @@
|
||||
# #225 — Fusion : panneau Spark, version du service d'agents visible en mode sidebar
|
||||
|
||||
**Contexte.** La version « Service d'agents vX » du service d'agents
|
||||
est visible dans la console pleine page (pilule d'en-tête, rendue par
|
||||
le service lui-même), mais pas dans le **panneau natif des
|
||||
applications** (mode sidebar) — or c'est là que Bruno vit au
|
||||
quotidien. Demande de Bruno (2026-10-26), carte fusion #64 : la
|
||||
version doit se lire dans le panneau ouvert, sans ouvrir la console.
|
||||
|
||||
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
|
||||
maintenu en canonique dans TikTak et recopié dans les 4 applications
|
||||
(TikTak, Flowdeck, Kanbante, ObsiGate ; chaque copie garde son bloc
|
||||
d'injection d'origine propre à l'hôte, le corps est commun) :
|
||||
|
||||
- le pied du panneau gagne une ligne dédiée
|
||||
`.draco-panneau-version`, discrète (11,5 px, opacité 0,6,
|
||||
monochrome, aucun emoji), **masquée tant qu'aucune version n'est
|
||||
connue** ;
|
||||
- à l'ouverture du panneau, `chargerVersion()` interroge la santé
|
||||
du service — `GET /healthz` de l'origine que le panneau connaît
|
||||
déjà (celle injectée par l'hôte, ici `window.DRACO_URLS.agent`) —
|
||||
et affiche « Service d'agents vX » depuis le champ `version` de
|
||||
la réponse (la même source que la pilule de la console) ;
|
||||
- cache de session de 5 minutes, rafraîchissement au rythme des
|
||||
ouvertures du panneau (comme la vérification d'identité) ;
|
||||
- **jamais de valeur codée en dur** : réponse illisible ou service
|
||||
injoignable = la ligne reste masquée — pas de fausse version, pas
|
||||
d'erreur visible.
|
||||
|
||||
**Preuve.** Épreuve comportementale (jsdom) exécutée sur les 4
|
||||
copies avec le contrat d'injection de chaque hôte : santé joignable
|
||||
→ la ligne affiche « Service d'agents v0.38.0 » (version lue sur
|
||||
l'origine de l'agent) ; santé injoignable → rien d'affiché ; hôte
|
||||
sans injection → aucun appel. Test de garde :
|
||||
`tests/test_draco_barre.py::test_panneau_version_service_affichee_225`.
|
||||
|
||||
**Même livraison** (carte fusion #64) : TikTak 0.61.3, Flowdeck
|
||||
7.83.1, Kanbante 0.37.1. Côté ObsiGate, la prise d'effet attend le
|
||||
**redéploiement Docker** (geste de Bruno), comme les items panneau
|
||||
précédents.
|
||||
@@ -0,0 +1,94 @@
|
||||
# #224 — Sécurité des dépendances : pip-audit vierge (brotli, filelock, opentelemetry-api, pypdf)
|
||||
|
||||
> Ouvert le 2026-10-25. Déclencheur : le job `security` du run CI 2303
|
||||
> (commit `f29a228`, version 2.78.0) échoue sur `pip-audit` (bloquant,
|
||||
> #87) — **17 vulnérabilités connues dans 4 paquets**. Le job `lint`
|
||||
> du même run est vert ; `security` est le seul job terminé en échec
|
||||
> au moment du constat (`test` en file, `build`/`e2e` en attente).
|
||||
|
||||
## Constat (journal du job `security`, run 2303)
|
||||
|
||||
| Paquet | Version auditée | Vulnérabilités | Corrigée en |
|
||||
|---|---|---|---|
|
||||
| brotli | 1.2.0 | PYSEC-2026-4322, GHSA-9v8h-qqgm-4m35 | 1.2.1 |
|
||||
| filelock | 3.32.3 | CVE-2026-112940, CVE-2026-112913, CVE-2026-112907 | 4.2.1 |
|
||||
| opentelemetry-api | 1.37.0 | CVE-2026-114603 | 1.45.1 |
|
||||
| pypdf | 6.19.0 | CVE-2026-113792, CVE-2026-113795, CVE-2026-113793, CVE-2026-113794, CVE-2026-113786, CVE-2026-113791, CVE-2026-113790, CVE-2026-113789, CVE-2026-115572, CVE-2026-115585, CVE-2026-115580 | 6.20.0 → 6.21.2 selon l'advisory (max 6.21.2) |
|
||||
|
||||
Total : 2 + 3 + 1 + 11 = **17 vulnérabilités**.
|
||||
|
||||
Audit local de confirmation, en deux temps : (1) un venv épinglé sur
|
||||
les versions du runner (brotli 1.2.0, filelock 3.32.3,
|
||||
opentelemetry-api 1.37.0, pypdf 6.19.0) reproduit **exactement** les
|
||||
17 vulnérabilités et les versions corrigées du tableau ci-dessus ;
|
||||
(2) dans le venv du dépôt, `pip-audit` (avec les deux ignores déjà en
|
||||
place dans la CI) ne ressortait avant correctif que brotli 1.2.0
|
||||
(2 advisories, fix 1.2.1) et pypdf 6.21.1 (3 advisories restants,
|
||||
fix 6.21.2) — ce venv a déjà filelock 4.2.4 (corrigée) et ne contient
|
||||
pas opentelemetry-api. L'écart avec la CI vient de l'environnement
|
||||
audité, pas des dépendances déclarées (voir cause).
|
||||
|
||||
## Cause racine
|
||||
|
||||
Même piège que BUG-093 / BUG-095, déjà documenté dans
|
||||
`backend/requirements.txt` et `.gitea/workflows/ci.yml` : `pip-audit`
|
||||
audite **tout l'environnement Python du runner**, dont les paquets
|
||||
préinstallés dans la toolcache de l'image `catthehacker/ubuntu:act-latest`.
|
||||
`pip install -r backend/requirements.txt` répond « already satisfied »
|
||||
pour toute exigence transitive déjà satisfaite par la toolcache et
|
||||
n'aligne jamais la version vulnérable :
|
||||
|
||||
- **pypdf 6.19.0** : préinstallée ; le plancher `>=6.16.1` (BUG-093)
|
||||
est satisfait, pip ne monte pas vers 6.21.2.
|
||||
- **brotli 1.2.0** : transitive de `fonttools[woff]` (via weasyprint),
|
||||
exigée en `>=1.0.1` seulement.
|
||||
- **filelock 3.32.3** : transitive de CacheControl (outil `pip-audit`
|
||||
lui-même) et préinstallée ; exigée en `>=3.8.0` seulement.
|
||||
- **opentelemetry-api 1.37.0** : préinstallée dans la toolcache,
|
||||
aucune dépendance d'ObsiGate ne la demande.
|
||||
|
||||
## Correctif
|
||||
|
||||
Planchers de sécurité explicites dans `backend/requirements.txt`,
|
||||
selon le patron déjà en place (pyjwt, urllib3, multidict, certifi) —
|
||||
aucune autre dépendance touchée, aucun code applicatif modifié :
|
||||
|
||||
- `pypdf>=6.16.1` → **`pypdf>=6.21.2`** (plancher existant relevé) ;
|
||||
- **`brotli>=1.2.1`** (nouveau plancher) ;
|
||||
- **`filelock>=4.2.1`** (nouveau plancher) ;
|
||||
- **`opentelemetry-api>=1.45.1`** (nouveau plancher).
|
||||
|
||||
Garde-fou : `tests/test_ci_workflow.py::TestDependencySecurityFloors`
|
||||
couvre désormais aussi brotli, filelock et opentelemetry-api, et le
|
||||
plancher verrouillé de pypdf passe à (6, 21, 2). Le commentaire de
|
||||
l'étape Pip-audit de `.gitea/workflows/ci.yml` est mis à jour en
|
||||
conséquence (aucune commande CI modifiée).
|
||||
|
||||
## Exceptions (ignores)
|
||||
|
||||
**Aucun nouvel ignore.** Les deux exceptions déjà en place dans la
|
||||
CI restent inchangées et ne concernent pas ces paquets :
|
||||
`PYSEC-2026-1325` (ecdsa, aucun correctif upstream, chemins ECDSA
|
||||
jamais exécutés — HS256 uniquement) et `CVE-2026-85394`
|
||||
(python-jose, aucun correctif upstream, non atteignable — voir le
|
||||
commentaire de l'étape dans `ci.yml`).
|
||||
|
||||
## Vérification
|
||||
|
||||
- `pip-audit` local (mêmes ignores que la CI) après alignement de
|
||||
l'environnement sur les nouveaux planchers : **0 vulnérabilité
|
||||
non ignorée**.
|
||||
- Portes locales du workflow : `pytest tests/`, `ruff check backend/`,
|
||||
`mypy backend/ --ignore-missing-imports`, tests frontend
|
||||
(`validate-imports` + `unit`) — voir le commit #224.
|
||||
- CI Gitea complète verte exigée (lint, test, security, build, e2e)
|
||||
avant de considérer #224 livré.
|
||||
|
||||
## Livraison
|
||||
|
||||
- Commit conventionnel `fix: … #224` ; le hook de version porte
|
||||
2.78.0 → **2.78.1** (correctif) et crée le tag `v2.78.1`, publié
|
||||
au push ; release Gitea créée sur ce tag après vérification de sa
|
||||
cible.
|
||||
- **Redéploiement Docker requis** pour que l'image embarque les
|
||||
dépendances alignées (l'hôte actuel est l'environnement dev).
|
||||
+4
-4
@@ -19,8 +19,8 @@
|
||||
var mode = localStorage.getItem("obsigate-theme-mode") || "dark";
|
||||
root.setAttribute("data-theme", mode);
|
||||
var light = mode === "light" || mode === "sepia";
|
||||
root.style.setProperty("--bg-primary", light ? "#ffffff" : "#0f1117");
|
||||
root.style.setProperty("--text-primary", light ? "#1a1a1a" : "#e6edf3");
|
||||
root.style.setProperty("--bg-primary", light ? "#fbfbfa" : "#17181a");
|
||||
root.style.setProperty("--text-primary", light ? "#242624" : "#eceeeb");
|
||||
} catch (e) { root.setAttribute("data-theme", "dark"); }
|
||||
})();
|
||||
</script>
|
||||
@@ -41,8 +41,8 @@
|
||||
min-height: 100vh;
|
||||
overflow: auto; /* override SPA style.css: body{overflow:hidden} */
|
||||
height: auto; /* override SPA style.css: body{height:100vh} */
|
||||
background: var(--bg-primary, #0f1117);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
background: var(--bg-primary, #17181a);
|
||||
color: var(--text-primary, #eceeeb);
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables
|
||||
* d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */
|
||||
.draco-alertes-overlay {
|
||||
position: fixed; inset: 0; z-index: 30000;
|
||||
display: none; align-items: flex-start; justify-content: center;
|
||||
padding: 8vh 16px 16px;
|
||||
background: rgb(0 0 0 / .55); overflow-y: auto;
|
||||
}
|
||||
.draco-alertes-box {
|
||||
width: 720px; max-width: 100%;
|
||||
background: var(--bg-primary, #1f1f1f);
|
||||
color: var(--text, #eceee9);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5);
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.draco-alertes-head h2 { margin: 0; font-size: 18px; }
|
||||
.draco-alertes-close {
|
||||
border: 1px solid var(--border, #333); background: transparent;
|
||||
color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer;
|
||||
}
|
||||
.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; }
|
||||
.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; }
|
||||
.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; }
|
||||
.draco-alertes-item {
|
||||
border: 1px solid var(--border, #2a2a2a); border-radius: 10px;
|
||||
padding: 9px 12px; margin: 6px 0;
|
||||
}
|
||||
.draco-alertes-item.is-active { border-color: #e8762c; }
|
||||
.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; }
|
||||
.draco-alertes-title:hover { text-decoration: underline; }
|
||||
.draco-alertes-detail {
|
||||
font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
}
|
||||
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
|
||||
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
|
||||
.draco-alertes-empty a { color: inherit; }
|
||||
/* Chantier 5 — onglets + filtre (mêmes variables d'hôte, mêmes replis). */
|
||||
.draco-alertes-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 12px; }
|
||||
.draco-alertes-tab {
|
||||
border: 1px solid var(--border, #333); background: transparent;
|
||||
color: inherit; border-radius: 999px; padding: 5px 11px;
|
||||
cursor: pointer; font-size: 13px; line-height: 1.3;
|
||||
}
|
||||
.draco-alertes-tab[aria-selected="true"] { background: #e8762c; border-color: #e8762c; color: #fff; }
|
||||
.draco-alertes-tab:focus-visible { outline: 2px solid #e8762c; outline-offset: 2px; }
|
||||
.draco-alertes-filtre { display: flex; gap: 8px; align-items: center; margin: 0 0 4px; }
|
||||
.draco-alertes-filtre label { font-size: 13px; color: var(--text-dim, #999); }
|
||||
.draco-alertes-filtre input {
|
||||
flex: 1; min-width: 0; background: transparent; color: inherit;
|
||||
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.draco-alertes-compte { font-size: 12.5px; color: var(--text-dim, #999); margin: 4px 0 10px; min-height: 1em; }
|
||||
.draco-alertes-compte:empty { display: none; }
|
||||
.draco-alertes-item.is-error { border-color: #c2437e; }
|
||||
@@ -0,0 +1,749 @@
|
||||
/* Alertes unifiées dracodev — Fusion #69 (chantier 2) + chantier 5
|
||||
* (onglets + composant partagé + onglet Console Spark).
|
||||
*
|
||||
* SOURCE CANONIQUE : Projets/dracodev-commons, ui/alertes/. Les copies
|
||||
* dans les 4 hôtes (TikTak, Kanbante, Flowdeck, ObsiGate) sont des
|
||||
* vendoring byte-identiques de ce fichier — ne pas les modifier sur
|
||||
* place, toute évolution passe par commons puis re-vendoring.
|
||||
*
|
||||
* Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
|
||||
* ouverture du panneau (et au chargement, pour la pastille), le module
|
||||
* interroge les API de notifications réellement exposées par chaque
|
||||
* application, avec les identifiants du navigateur (credentials:
|
||||
* "include"). Aucune alerte n'est inventée : une source injoignable
|
||||
* ou non authentifiée affiche son état réel par section (une section
|
||||
* en échec n'empêche jamais de consulter les autres), jamais du vide
|
||||
* déguisé. En attendant le correctif CORS des allowlists (autre
|
||||
* chantier), une section inter-origines peut afficher cet état
|
||||
* d'erreur : c'est un état d'erreur transitoire, pas un comportement
|
||||
* normal — aucun état « Injoignable » systématique n'est présenté
|
||||
* comme normal.
|
||||
*
|
||||
* Sources réelles (audit #69, 2026-10-28, inchangé) :
|
||||
* - Flowdeck : GET {base}/api/notifications?limit=50
|
||||
* -> { notifications: [{id, ntype, title, message, url, is_read,
|
||||
* created_at}], unread } ; active = is_read = 0 ; lien = base + url.
|
||||
* - TikTak : GET {base}/api/notifications
|
||||
* -> [{id, kind, title, body, task_id, is_read, created_at}] ;
|
||||
* active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon
|
||||
* base + "/notifications".
|
||||
* - Kanbante : GET {base}/api/events?limit=100
|
||||
* -> { events: [{repo_full_name, issue_number, event_type,
|
||||
* from_column, to_column, created_at}] } ; AUCUN état lu/non lu
|
||||
* n'existe dans Kanbante, donc tout y est historique et rien n'y
|
||||
* est compté comme actif ; lien = base + "/board/" + repo.
|
||||
* - ObsiGate : AUCUNE notification entrante consultable. Ses
|
||||
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets
|
||||
* masqués), pas un journal lisible par l'utilisateur. L'onglet
|
||||
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
|
||||
* - Console Spark : AUCUN endpoint d'alertes console n'est publié
|
||||
* sur agent-commun main au moment de ce chantier (autre chantier
|
||||
* en cours). L'onglet Console Spark affiche donc un état vide
|
||||
* HONNÊTE (« source indisponible », lien vers la console) et ne
|
||||
* fabrique aucune alerte. Branchement prévu, sans deviner d'URL :
|
||||
* poser window.DRACO_ALERTES_CONSOLE_URL (ou
|
||||
* DRACO_BAR_CONFIG.consoleAlertsUrl) vers l'endpoint livré par
|
||||
* l'autre chantier ; forme attendue : tableau, ou
|
||||
* {alerts:[...]} / {items:[...]}, chaque alerte portant title,
|
||||
* detail|message|context, date|created_at, active (booléen) ou
|
||||
* status/state (pause budget, accès requis, en attente, fermée…),
|
||||
* url|link. Les missions en pause budget / accès requis et les
|
||||
* approbations en attente y apparaîtront avec leurs états réels.
|
||||
*
|
||||
* Résolution des bases (même ordre que barre-apps v0.1.6) :
|
||||
* 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
|
||||
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
|
||||
* 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
|
||||
* 3. href rendu de l'onglet .draco-apptab[data-app] (l'onglet
|
||||
* « Console Spark » ajouté par le chantier 5 porte data-app
|
||||
* "agent", donc la base agent suit le même ordre) ;
|
||||
* 4. défaut officiel (production).
|
||||
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
|
||||
* lit aussi window.DRACO_URLS (injecté par son backend, clé "agent"
|
||||
* incluse).
|
||||
* Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
|
||||
* que sur un hôte -dev, exactement comme la barre.
|
||||
*
|
||||
* Présentation (identique partout, imposée par ce seul fichier) :
|
||||
* champs par alerte = application, titre, date, état, lien profond
|
||||
* (environnement -dev respecté via la résolution ci-dessus) ;
|
||||
* ordre = date décroissante dans chaque onglet (sans date en
|
||||
* dernier) ; format de date = toLocaleString("fr-FR"), « sans date »
|
||||
* si absente ou illisible ; libellés d'état affichés = « Active »,
|
||||
* « Lue », ou le libellé d'événement Kanbante (ex. « Fermée »),
|
||||
* ou l'état réel console quand la source existe. Le type brut
|
||||
* Kanbante (« moved »…) n'est plus affiché : il dupliquait le
|
||||
* libellé d'état en anglais.
|
||||
* Onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
|
||||
* Console Spark, Historique — dans cet ordre. Tout et chaque onglet
|
||||
* par application ne montrent QUE les alertes actives ; Historique
|
||||
* ne montre QUE les alertes fermées (lues / événements Kanbante /
|
||||
* console fermées). Le filtre texte (titre + contexte) s'applique
|
||||
* APRÈS le périmètre de l'onglet courant : il ne mélange jamais
|
||||
* actifs et historique. L'onglet courant est dans l'URL
|
||||
* (#alertes=<onglet>, partageable, retour arrière via pushState).
|
||||
*
|
||||
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
|
||||
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
|
||||
* pour la pastille (repli : mise à jour directe du badge).
|
||||
* Insertion des données : textContent uniquement, jamais innerHTML
|
||||
* avec une valeur venue du serveur.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var DEFAULT_URLS = {
|
||||
obsigate: "https://og.dracodev.net/",
|
||||
flowdeck: "https://flowdeck.dracodev.net/",
|
||||
tiktak: "https://tiktak.dracodev.net/",
|
||||
kanbante: "https://kanbante.dracodev.net/",
|
||||
agent: "https://agent.dracodev.net/"
|
||||
};
|
||||
var DEV_URLS = {
|
||||
obsigate: "https://og-dev.dracodev.net/",
|
||||
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||
agent: "https://agent-dev.dracodev.net/"
|
||||
};
|
||||
var APP_LABELS = {
|
||||
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark"
|
||||
};
|
||||
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
|
||||
var ALL_APPS = ["obsigate", "flowdeck", "tiktak", "kanbante", "console"];
|
||||
var TABS = ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"];
|
||||
var TAB_LABELS = {
|
||||
tout: "Tout", obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante",
|
||||
console: "Console Spark", historique: "Historique"
|
||||
};
|
||||
var EVENT_LABELS = {
|
||||
created: "créée", moved: "déplacée", closed: "fermée",
|
||||
reopened: "rouverte", commented: "commentée"
|
||||
};
|
||||
var CONSOLE_ACTIVE_STATUSES = [
|
||||
"active", "pending", "pause", "pause_budget", "acces_requis",
|
||||
"en_attente", "en attente", "requise", "awaiting"
|
||||
];
|
||||
var CONSOLE_CLOSED_STATUSES = [
|
||||
"closed", "resolved", "resolue", "lue", "terminee", "done", "fermee"
|
||||
];
|
||||
|
||||
function isDevHost(hostname) {
|
||||
if (!hostname) return false;
|
||||
var h = String(hostname).toLowerCase();
|
||||
if (h.slice(-13) !== ".dracodev.net") return false;
|
||||
var first = h.split(".")[0];
|
||||
return first.length > 4 && first.slice(-4) === "-dev";
|
||||
}
|
||||
function cleanUrl(u) {
|
||||
if (!u || typeof u !== "string") return null;
|
||||
if (u.indexOf("{{") !== -1) return null;
|
||||
if (!u.replace(/\s/g, "")) return null;
|
||||
return u;
|
||||
}
|
||||
function withSlash(u) {
|
||||
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
|
||||
}
|
||||
function joinUrl(base, path) {
|
||||
var b = withSlash(base);
|
||||
if (!path) return b;
|
||||
if (/^https?:\/\//.test(path)) return path;
|
||||
return b + String(path).replace(/^\//, "");
|
||||
}
|
||||
function parseDate(s) {
|
||||
if (!s) return null;
|
||||
var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1
|
||||
? String(s).replace(" ", "T") + "Z" : s);
|
||||
return isNaN(t.getTime()) ? null : t;
|
||||
}
|
||||
function fmtDate(s) {
|
||||
var t = parseDate(s);
|
||||
if (!t) return "";
|
||||
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
|
||||
}
|
||||
function capitalize(s) {
|
||||
s = String(s || "");
|
||||
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
|
||||
}
|
||||
|
||||
/* Résolution des bases. `env` permet les tests sans navigateur :
|
||||
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls, consoleUrl }. */
|
||||
function resolveUrls(env) {
|
||||
env = env || {};
|
||||
var hostname = env.hostname;
|
||||
if (hostname === undefined) {
|
||||
try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; }
|
||||
}
|
||||
var dev = isDevHost(hostname);
|
||||
var urls = {};
|
||||
ALL_APPS.forEach(function (key) {
|
||||
var app = key === "console" ? "agent" : key;
|
||||
var out = key === "console" ? "agent" : key;
|
||||
var u = cleanUrl(env.barUrls && (env.barUrls[key] || env.barUrls[app]))
|
||||
|| cleanUrl(env.configUrls && (env.configUrls[key] || env.configUrls[app]));
|
||||
if (!u && dev) u = DEV_URLS[app] || null;
|
||||
if (!u) {
|
||||
var href = null;
|
||||
if (env.tabHrefs && (env.tabHrefs[key] || env.tabHrefs[app])) href = env.tabHrefs[key] || env.tabHrefs[app];
|
||||
else if (typeof document !== "undefined" && document.querySelector) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
href = tab ? tab.getAttribute("href") : null;
|
||||
}
|
||||
u = cleanUrl(href);
|
||||
}
|
||||
if (!u && env.dracoUrls && (env.dracoUrls[key] || env.dracoUrls[app])) u = cleanUrl(env.dracoUrls[key] || env.dracoUrls[app]);
|
||||
if (!u) {
|
||||
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
|
||||
try {
|
||||
if (global.DRACO_URLS && (global.DRACO_URLS[key] || global.DRACO_URLS[app])) u = cleanUrl(global.DRACO_URLS[key] || global.DRACO_URLS[app]);
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
if (!u) u = DEFAULT_URLS[app];
|
||||
urls[out] = withSlash(u);
|
||||
if (key === "console") urls.console = withSlash(u);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
function browserEnv() {
|
||||
var cfg = global.DRACO_BAR_CONFIG || {};
|
||||
var bar = global.DRACO_BAR || {};
|
||||
var tabs = {};
|
||||
try {
|
||||
if (typeof document !== "undefined" && document.querySelectorAll) {
|
||||
var nodes = document.querySelectorAll(".draco-apptab[data-app]");
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href");
|
||||
}
|
||||
}
|
||||
} catch (e) { /* rien */ }
|
||||
return {
|
||||
barUrls: bar.urls || null, configUrls: cfg.urls || null,
|
||||
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null,
|
||||
consoleUrl: null
|
||||
};
|
||||
}
|
||||
/* URL de la source console, si l'hôte l'a configurée. AUCUNE URL
|
||||
* n'est devinée : sans configuration, la source est indisponible
|
||||
* (état vide honnête, voir renderCurrent). */
|
||||
function consoleSourceUrl(env) {
|
||||
env = env || {};
|
||||
if (env.consoleUrl) return env.consoleUrl;
|
||||
try {
|
||||
if (global.DRACO_ALERTES_CONSOLE_URL) return global.DRACO_ALERTES_CONSOLE_URL;
|
||||
var cfg = global.DRACO_BAR_CONFIG || {};
|
||||
if (cfg.consoleAlertsUrl) return cfg.consoleAlertsUrl;
|
||||
} catch (e) { /* rien */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeFlowdeck(base, payload) {
|
||||
var list = (payload && payload.notifications) || [];
|
||||
return list.map(function (n) {
|
||||
return {
|
||||
app: "flowdeck", id: n.id, title: n.title || "(sans titre)",
|
||||
detail: n.message || "", date: n.created_at || "",
|
||||
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||
kind: n.ntype || "", url: joinUrl(base, n.url || "")
|
||||
};
|
||||
});
|
||||
}
|
||||
function normalizeTiktak(base, payload) {
|
||||
var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || [];
|
||||
return list.map(function (n) {
|
||||
return {
|
||||
app: "tiktak", id: n.id, title: n.title || "(sans titre)",
|
||||
detail: n.body || "", date: n.created_at || "",
|
||||
active: !n.is_read, state: n.is_read ? "lue" : "active",
|
||||
kind: n.kind || "",
|
||||
url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications")
|
||||
};
|
||||
});
|
||||
}
|
||||
function normalizeKanbante(base, payload) {
|
||||
var list = (payload && payload.events) || [];
|
||||
return list.map(function (e) {
|
||||
var label = EVENT_LABELS[e.event_type] || e.event_type || "événement";
|
||||
var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || "");
|
||||
return {
|
||||
app: "kanbante", id: e.id,
|
||||
title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label,
|
||||
detail: detail, date: e.created_at || "",
|
||||
active: false, state: label, kind: e.event_type || "",
|
||||
url: joinUrl(base, "board/" + (e.repo_full_name || ""))
|
||||
};
|
||||
});
|
||||
}
|
||||
function normalizeConsole(base, payload) {
|
||||
var list = Array.isArray(payload) ? payload
|
||||
: (payload && (payload.alerts || payload.items)) || [];
|
||||
return list.map(function (n) {
|
||||
var status = String(n.status || "").toLowerCase().replace(/_/g, " ").trim();
|
||||
var stateRaw = String(n.state || status || "");
|
||||
var active;
|
||||
if (typeof n.active === "boolean") active = n.active;
|
||||
else if (status && CONSOLE_ACTIVE_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = true;
|
||||
else if (status && CONSOLE_CLOSED_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = false;
|
||||
else if (/pause|acc[eè]s|attente/.test(stateRaw.toLowerCase())) active = true;
|
||||
else if (/ferm|résol|resol|termin/.test(stateRaw.toLowerCase())) active = false;
|
||||
else active = true; /* source réelle, statut inconnu : on l'expose en active */
|
||||
return {
|
||||
app: "console", id: n.id, title: n.title || "(sans titre)",
|
||||
detail: n.detail || n.message || n.context || "", date: n.date || n.created_at || "",
|
||||
active: active, state: stateRaw || (active ? "en attente" : "fermée"),
|
||||
kind: n.kind || n.type || "",
|
||||
url: n.url || n.link ? joinUrl(base, n.url || n.link) : withSlash(base)
|
||||
};
|
||||
});
|
||||
}
|
||||
/* Libellé d'état AFFICHÉ, uniforme partout (les valeurs brutes de
|
||||
* normalize* sont inchangées, pour compatibilité des tests #69). */
|
||||
function displayState(a) {
|
||||
if (a.active) return "Active";
|
||||
if (a.app === "kanbante") return capitalize(a.state);
|
||||
if (a.app === "console") return capitalize(a.state);
|
||||
return "Lue";
|
||||
}
|
||||
function metaLine(a) {
|
||||
var date = fmtDate(a.date) || "sans date";
|
||||
/* Kanbante : le type brut (« moved ») dupliquait le libellé
|
||||
* d'état ; il n'est plus affiché. Les autres apps gardent leur
|
||||
* type quand il apporte une information. */
|
||||
if (a.app === "kanbante") return APP_LABELS[a.app] + " · " + date + " · " + displayState(a);
|
||||
var kind = a.kind ? " · " + a.kind : "";
|
||||
return APP_LABELS[a.app] + kind + " · " + date + " · " + displayState(a);
|
||||
}
|
||||
/* Actives d'abord, puis date décroissante (sans date en dernier).
|
||||
* Dans un onglet, le périmètre est déjà homogène (que des actives
|
||||
* ou que des fermées) : l'ordre effectif est donc la date décroissante. */
|
||||
function sortAlerts(list) {
|
||||
return list.slice().sort(function (a, b) {
|
||||
if (a.active !== b.active) return a.active ? -1 : 1;
|
||||
var da = parseDate(a.date), db = parseDate(b.date);
|
||||
if (da && db) return db - da;
|
||||
if (da) return -1;
|
||||
if (db) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
function countActive(list) {
|
||||
var n = 0;
|
||||
list.forEach(function (a) { if (a.active) n++; });
|
||||
return n;
|
||||
}
|
||||
function allItems(results) {
|
||||
var all = [];
|
||||
ALL_APPS.forEach(function (app) {
|
||||
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
|
||||
});
|
||||
return all;
|
||||
}
|
||||
/* Périmètre d'un onglet. Retour : { items, errors, consoleMissing }.
|
||||
* Tout et onglets par application = actives seulement ; Historique =
|
||||
* fermées seulement. Les erreurs de section sont remontées dans
|
||||
* chaque onglet concerné (jamais silencieusement avalées). */
|
||||
function scopeAlerts(all, tab, results) {
|
||||
results = results || {};
|
||||
var items = [], errors = [];
|
||||
function collect(app, pred) {
|
||||
if (results[app] && results[app].error) errors.push({ app: app, error: results[app].error });
|
||||
all.forEach(function (a) { if (a.app === app && pred(a)) items.push(a); });
|
||||
}
|
||||
if (tab === "historique") {
|
||||
ALL_APPS.forEach(function (app) { collect(app, function (a) { return !a.active; }); });
|
||||
} else if (tab === "tout") {
|
||||
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
|
||||
} else if (ALL_APPS.indexOf(tab) !== -1) {
|
||||
collect(tab, function (a) { return a.active; });
|
||||
} else {
|
||||
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
|
||||
}
|
||||
return {
|
||||
items: sortAlerts(items), errors: errors,
|
||||
consoleMissing: !!(results.console && results.console.sourceMissing)
|
||||
};
|
||||
}
|
||||
function countByTab(all, results) {
|
||||
var counts = {};
|
||||
TABS.forEach(function (tab) { counts[tab] = scopeAlerts(all, tab, results).items.length; });
|
||||
return counts;
|
||||
}
|
||||
/* Filtre texte, appliqué APRÈS le périmètre de l'onglet : titre ou
|
||||
* contexte, insensible à la casse, sans jamais mélanger actifs et
|
||||
* historique (le périmètre est déjà fixé par scopeAlerts). */
|
||||
function filterAlerts(list, q) {
|
||||
q = String(q || "").trim().toLowerCase();
|
||||
if (!q) return list.slice();
|
||||
return list.filter(function (a) {
|
||||
return (String(a.title || "").toLowerCase().indexOf(q) !== -1)
|
||||
|| (String(a.detail || "").toLowerCase().indexOf(q) !== -1);
|
||||
});
|
||||
}
|
||||
/* Onglet depuis le hash d'URL : "#alertes=historique" -> "historique",
|
||||
* "#alertes" -> "tout", autre -> null. */
|
||||
function parseAlertTab(hash) {
|
||||
if (!hash) return null;
|
||||
var h = String(hash).replace(/^#/, "");
|
||||
if (h === "alertes") return "tout";
|
||||
var m = /^alertes=([a-z]+)$/.exec(h);
|
||||
if (m && TABS.indexOf(m[1]) !== -1) return m[1];
|
||||
return null;
|
||||
}
|
||||
function hashForTab(tab) {
|
||||
if (TABS.indexOf(tab) === -1) tab = "tout";
|
||||
return "#alertes=" + tab;
|
||||
}
|
||||
|
||||
/* ---- Rendu (navigateur seulement) ---- */
|
||||
var panel = null, listEl = null, tabsEl = null, filterEl = null,
|
||||
countEl = null, badgeTimer = null;
|
||||
var currentTab = "tout", currentData = null, currentFilter = "";
|
||||
var panelPushed = false, closingViaBack = false, suppressHash = false;
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var d = document.createElement(tag);
|
||||
if (cls) d.className = cls;
|
||||
if (text !== undefined && text !== null) d.textContent = text;
|
||||
return d;
|
||||
}
|
||||
function ensurePanel() {
|
||||
if (panel) return panel;
|
||||
var ov = el("div", "draco-alertes-overlay");
|
||||
ov.setAttribute("role", "dialog");
|
||||
ov.setAttribute("aria-label", "Alertes unifiées");
|
||||
var box = el("div", "draco-alertes-box");
|
||||
var head = el("div", "draco-alertes-head");
|
||||
head.appendChild(el("h2", "", "Alertes unifiées"));
|
||||
var close = el("button", "draco-alertes-close", "✕");
|
||||
close.setAttribute("aria-label", "Fermer");
|
||||
close.addEventListener("click", function () { closePanel(); });
|
||||
head.appendChild(close);
|
||||
box.appendChild(head);
|
||||
box.appendChild(el("p", "draco-alertes-note",
|
||||
"Agrégation à la demande, pas une fédération temps réel : " +
|
||||
"les alertes sont relues dans chaque application à l'ouverture. " +
|
||||
"Une section en échec n'empêche pas de consulter les autres."));
|
||||
tabsEl = el("div", "draco-alertes-tabs");
|
||||
tabsEl.setAttribute("role", "tablist");
|
||||
tabsEl.setAttribute("aria-label", "Alertes par application");
|
||||
tabsEl.addEventListener("keydown", onTabsKeydown);
|
||||
box.appendChild(tabsEl);
|
||||
var filtre = el("div", "draco-alertes-filtre");
|
||||
var lbl = el("label", "", "Filtrer");
|
||||
lbl.setAttribute("for", "draco-alertes-filtre-input");
|
||||
filtre.appendChild(lbl);
|
||||
filterEl = el("input", "");
|
||||
filterEl.type = "search";
|
||||
filterEl.id = "draco-alertes-filtre-input";
|
||||
filterEl.setAttribute("placeholder", "Filtrer par titre ou contexte, dans cet onglet…");
|
||||
filterEl.setAttribute("aria-label", "Filtrer les alertes de l'onglet courant");
|
||||
filterEl.addEventListener("input", function () {
|
||||
currentFilter = filterEl.value;
|
||||
renderCurrent();
|
||||
});
|
||||
filtre.appendChild(filterEl);
|
||||
box.appendChild(filtre);
|
||||
countEl = el("p", "draco-alertes-compte", "");
|
||||
box.appendChild(countEl);
|
||||
listEl = el("div", "draco-alertes-list");
|
||||
listEl.setAttribute("role", "tabpanel");
|
||||
box.appendChild(listEl);
|
||||
ov.appendChild(box);
|
||||
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
|
||||
document.addEventListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape" && panel && panel.style.display !== "none") closePanel();
|
||||
});
|
||||
document.body.appendChild(ov);
|
||||
panel = ov;
|
||||
return ov;
|
||||
}
|
||||
function closePanel() {
|
||||
if (!panel) return;
|
||||
panel.style.display = "none";
|
||||
if (panelPushed) {
|
||||
panelPushed = false;
|
||||
closingViaBack = true;
|
||||
try { history.back(); } catch (e) { closingViaBack = false; }
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var h = parseAlertTab(window.location.hash);
|
||||
if (h) {
|
||||
suppressHash = true;
|
||||
history.replaceState(null, "", window.location.pathname + window.location.search);
|
||||
suppressHash = false;
|
||||
}
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
function onHashChange() {
|
||||
if (suppressHash) return;
|
||||
if (closingViaBack) { closingViaBack = false; return; }
|
||||
var tab = parseAlertTab(window.location.hash);
|
||||
if (tab) {
|
||||
if (!panel || panel.style.display === "none") openPanel(tab);
|
||||
else setTab(tab, false);
|
||||
} else if (panel && panel.style.display !== "none") {
|
||||
panelPushed = false;
|
||||
panel.style.display = "none";
|
||||
}
|
||||
}
|
||||
function onTabsKeydown(ev) {
|
||||
var keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
|
||||
if (keys.indexOf(ev.key) === -1) return;
|
||||
var btns = tabsEl.querySelectorAll(".draco-alertes-tab");
|
||||
var idx = TABS.indexOf(currentTab);
|
||||
if (ev.key === "ArrowLeft") idx = (idx + TABS.length - 1) % TABS.length;
|
||||
else if (ev.key === "ArrowRight") idx = (idx + 1) % TABS.length;
|
||||
else if (ev.key === "Home") idx = 0;
|
||||
else if (ev.key === "End") idx = TABS.length - 1;
|
||||
ev.preventDefault();
|
||||
setTab(TABS[idx], true);
|
||||
if (btns[idx] && btns[idx].focus) btns[idx].focus();
|
||||
}
|
||||
function renderTabs() {
|
||||
if (!tabsEl || !currentData) return;
|
||||
tabsEl.textContent = "";
|
||||
var all = allItems(currentData.results);
|
||||
var counts = countByTab(all, currentData.results);
|
||||
TABS.forEach(function (tab) {
|
||||
var b = el("button", "draco-alertes-tab",
|
||||
TAB_LABELS[tab] + " (" + counts[tab] + ")");
|
||||
b.type = "button";
|
||||
b.setAttribute("role", "tab");
|
||||
b.setAttribute("aria-selected", tab === currentTab ? "true" : "false");
|
||||
b.tabIndex = tab === currentTab ? 0 : -1;
|
||||
b.addEventListener("click", function () { setTab(tab, true); });
|
||||
tabsEl.appendChild(b);
|
||||
});
|
||||
}
|
||||
function renderRow(a) {
|
||||
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
|
||||
var main = el("div", "draco-alertes-main");
|
||||
var link = el("a", "draco-alertes-title", a.title);
|
||||
link.href = a.url;
|
||||
main.appendChild(link);
|
||||
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
|
||||
main.appendChild(el("div", "draco-alertes-meta", metaLine(a)));
|
||||
row.appendChild(main);
|
||||
return row;
|
||||
}
|
||||
function renderErrorRow(app, msg) {
|
||||
var row = el("div", "draco-alertes-item is-error");
|
||||
var main = el("div", "draco-alertes-main");
|
||||
main.appendChild(el("div", "draco-alertes-title", APP_LABELS[app] + " — section en échec"));
|
||||
main.appendChild(el("div", "draco-alertes-detail", msg));
|
||||
main.appendChild(el("div", "draco-alertes-meta",
|
||||
APP_LABELS[app] + " · section en échec — les autres sections restent consultables"));
|
||||
row.appendChild(main);
|
||||
return row;
|
||||
}
|
||||
function renderCurrent() {
|
||||
if (!listEl || !currentData) return;
|
||||
renderTabs();
|
||||
var scope = scopeAlerts(allItems(currentData.results), currentTab, currentData.results);
|
||||
var filtered = filterAlerts(scope.items, currentFilter);
|
||||
var q = String(currentFilter || "").trim();
|
||||
if (countEl) {
|
||||
countEl.textContent = q
|
||||
? filtered.length + " résultat(s) sur " + scope.items.length + " dans cet onglet — filtre « " + q + " »"
|
||||
: "";
|
||||
}
|
||||
listEl.textContent = "";
|
||||
/* Onglet Console sans source : état vide honnête, jamais d'alertes
|
||||
* inventées, avec le lien profond vers la console. */
|
||||
if (currentTab === "console" && scope.consoleMissing) {
|
||||
var sec = el("section", "draco-alertes-section");
|
||||
sec.appendChild(el("h3", "", "Console Spark (0)"));
|
||||
sec.appendChild(el("p", "draco-alertes-empty",
|
||||
"Aucune alerte console — source indisponible : l'endpoint " +
|
||||
"d'alertes console (agent-commun, autre chantier) n'est pas " +
|
||||
"encore publié. Les missions en pause budget / accès requis " +
|
||||
"et les approbations en attente apparaîtront ici, avec leurs " +
|
||||
"états réels, dès sa livraison."));
|
||||
var link = el("div", "draco-alertes-empty");
|
||||
var a = el("a", "", "Ouvrir la Console Spark");
|
||||
a.href = currentData.urls.agent || currentData.urls.console || "#";
|
||||
link.appendChild(a);
|
||||
sec.appendChild(link);
|
||||
listEl.appendChild(sec);
|
||||
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
|
||||
return;
|
||||
}
|
||||
if (currentTab === "obsigate" && !scope.items.length && !scope.errors.length) {
|
||||
var secOg = el("section", "draco-alertes-section");
|
||||
secOg.appendChild(el("h3", "", "ObsiGate (0)"));
|
||||
secOg.appendChild(el("p", "draco-alertes-empty",
|
||||
"ObsiGate n'expose aucune notification entrante consultable " +
|
||||
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
|
||||
var linkOg = el("div", "draco-alertes-empty");
|
||||
var aOg = el("a", "", "Ouvrir ObsiGate");
|
||||
aOg.href = currentData.urls.obsigate;
|
||||
linkOg.appendChild(aOg);
|
||||
secOg.appendChild(linkOg);
|
||||
listEl.appendChild(secOg);
|
||||
return;
|
||||
}
|
||||
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
|
||||
if (!scope.items.length && !scope.errors.length) {
|
||||
var empty = currentTab === "historique"
|
||||
? "Aucune alerte fermée dans les applications joignables."
|
||||
: currentTab === "tout"
|
||||
? "Aucune alerte active dans les applications joignables."
|
||||
: "Aucune alerte active pour " + TAB_LABELS[currentTab] + ".";
|
||||
if (currentTab === "console") empty = "Aucune alerte console.";
|
||||
listEl.appendChild(el("p", "draco-alertes-empty", empty));
|
||||
return;
|
||||
}
|
||||
if (q && !filtered.length) {
|
||||
listEl.appendChild(el("p", "draco-alertes-empty",
|
||||
"Aucune alerte dans cet onglet pour ce filtre."));
|
||||
return;
|
||||
}
|
||||
filtered.forEach(function (a) { listEl.appendChild(renderRow(a)); });
|
||||
}
|
||||
function setTab(tab, push) {
|
||||
if (TABS.indexOf(tab) === -1) tab = "tout";
|
||||
currentTab = tab;
|
||||
renderCurrent();
|
||||
if (push) {
|
||||
try {
|
||||
history.pushState(null, "", hashForTab(tab));
|
||||
panelPushed = true;
|
||||
} catch (e) { /* URL partageable : repli silencieux */ }
|
||||
}
|
||||
}
|
||||
|
||||
function fetchJson(url, timeoutMs) {
|
||||
var ctrl = null, timer = null;
|
||||
try {
|
||||
if (typeof AbortController !== "undefined") {
|
||||
ctrl = new AbortController();
|
||||
timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000);
|
||||
}
|
||||
} catch (e) { ctrl = null; }
|
||||
var opts = { credentials: "include" };
|
||||
if (ctrl) opts.signal = ctrl.signal;
|
||||
return fetch(url, opts).then(function (r) {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (!r.ok) {
|
||||
var err = new Error("HTTP " + r.status);
|
||||
err.status = r.status;
|
||||
throw err;
|
||||
}
|
||||
return r.json();
|
||||
}).catch(function (e) {
|
||||
if (timer) clearTimeout(timer);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
function loadAll() {
|
||||
var urls = resolveUrls(browserEnv());
|
||||
var consoleUrl = consoleSourceUrl(browserEnv());
|
||||
var jobs = {
|
||||
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
|
||||
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
|
||||
tiktak: fetchJson(urls.tiktak + "api/notifications")
|
||||
.then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }),
|
||||
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
|
||||
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
|
||||
};
|
||||
if (consoleUrl) {
|
||||
var agentBase = urls.agent || urls.console;
|
||||
jobs.console = fetchJson(consoleUrl)
|
||||
.then(function (p) { return { items: normalizeConsole(agentBase, p) }; });
|
||||
}
|
||||
var keys = Object.keys(jobs);
|
||||
return Promise.all(keys.map(function (k) {
|
||||
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
|
||||
var msg = (e && e.status === 401)
|
||||
? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau."
|
||||
: "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ").";
|
||||
return [k, { items: [], error: msg }];
|
||||
});
|
||||
})).then(function (pairs) {
|
||||
var results = { obsigate: { items: [] } };
|
||||
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
|
||||
if (!consoleUrl) results.console = { items: [], sourceMissing: true };
|
||||
return { urls: urls, results: results };
|
||||
});
|
||||
}
|
||||
function setBadge(n) {
|
||||
try {
|
||||
if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") {
|
||||
global.DRACO_BAR.setCounts({ alertes: n });
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* repli ci-dessous */ }
|
||||
try {
|
||||
document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) {
|
||||
b.textContent = n;
|
||||
b.hidden = !(n > 0);
|
||||
});
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
function refreshBadge() {
|
||||
return loadAll().then(function (r) {
|
||||
var n = countActive(allItems(r.results));
|
||||
setBadge(n);
|
||||
return n;
|
||||
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
|
||||
}
|
||||
function openPanel(initialTab) {
|
||||
ensurePanel();
|
||||
panel.style.display = "flex";
|
||||
var tab = initialTab || parseAlertTab(window.location.hash) || "tout";
|
||||
currentTab = tab;
|
||||
currentFilter = "";
|
||||
if (filterEl) filterEl.value = "";
|
||||
listEl.textContent = "";
|
||||
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
|
||||
if (tabsEl) tabsEl.textContent = "";
|
||||
loadAll().then(function (r) {
|
||||
currentData = r;
|
||||
/* Ouverture par le bouton (pas de hash) : l'onglet entre dans
|
||||
* l'URL pour être partageable ; ouverture par lien partagé : le
|
||||
* hash est déjà le bon, on ne pousse pas d'entrée en double. */
|
||||
var fromHash = parseAlertTab(window.location.hash);
|
||||
if (!fromHash) {
|
||||
try { history.pushState(null, "", hashForTab(tab)); panelPushed = true; } catch (e) { /* rien */ }
|
||||
}
|
||||
renderCurrent();
|
||||
setBadge(countActive(allItems(r.results)));
|
||||
}).catch(function () {
|
||||
currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } };
|
||||
renderCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
var api = {
|
||||
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
|
||||
_test: {
|
||||
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
|
||||
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
|
||||
normalizeConsole: normalizeConsole, consoleSourceUrl: consoleSourceUrl,
|
||||
sortAlerts: sortAlerts, countActive: countActive,
|
||||
filterAlerts: filterAlerts, scopeAlerts: scopeAlerts,
|
||||
countByTab: countByTab, parseAlertTab: parseAlertTab,
|
||||
hashForTab: hashForTab, displayState: displayState,
|
||||
metaLine: metaLine, fmtDate: fmtDate,
|
||||
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS,
|
||||
TABS: TABS, TAB_LABELS: TAB_LABELS, APP_LABELS: APP_LABELS
|
||||
}
|
||||
};
|
||||
global.DRACO_ALERTES = api;
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api._test;
|
||||
|
||||
/* Pastille au chargement (après les scripts defer de la barre) ;
|
||||
* un lien partagé #alertes=<onglet> ouvre directement le panneau
|
||||
* sur cet onglet. */
|
||||
if (typeof document !== "undefined" && document.addEventListener) {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (badgeTimer) clearTimeout(badgeTimer);
|
||||
badgeTimer = setTimeout(refreshBadge, 300);
|
||||
var tab = parseAlertTab(window.location.hash);
|
||||
if (tab) setTimeout(function () { openPanel(tab); }, 350);
|
||||
});
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
+84
-11
@@ -1,4 +1,12 @@
|
||||
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2)
|
||||
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2) ;
|
||||
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
||||
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
||||
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
|
||||
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
|
||||
v0.1.6 : menu compte (construit par le JS, mêmes jetons que le
|
||||
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 ».
|
||||
Jetons repris du prototype validé Console v7.2.
|
||||
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
||||
ou prefers-color-scheme si l'app intégratrice ne force rien. */
|
||||
@@ -7,6 +15,7 @@
|
||||
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
|
||||
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
|
||||
--agent: #c2437e; --danger: #cf4444;
|
||||
position: relative;
|
||||
height: 60px; box-sizing: border-box;
|
||||
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
|
||||
align-items: center; gap: 16px; padding: 0 12px;
|
||||
@@ -24,9 +33,53 @@
|
||||
.draco-bar-centre { justify-content: center; }
|
||||
.draco-bar-droite { justify-content: flex-end; }
|
||||
|
||||
/* Marque */
|
||||
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; }
|
||||
.draco-brand-mark { color: var(--agent); }
|
||||
/* Marque — bouton du menu des applications (v0.1.3) */
|
||||
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
|
||||
.draco-brand:hover { background: var(--surface-2); }
|
||||
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
|
||||
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
|
||||
|
||||
/* Menu des applications — construit par le JS depuis les onglets */
|
||||
.draco-apps-menu {
|
||||
position: absolute; top: calc(100% + 6px); left: 10px; z-index: 400;
|
||||
min-width: 248px; padding: 6px;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||
}
|
||||
.draco-apps-menu[hidden] { display: none; }
|
||||
|
||||
/* Menu compte (v0.1.6) — même boîte que le menu des apps, ancré à
|
||||
* 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;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||||
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||
}
|
||||
.draco-compte-menu[hidden] { display: none; }
|
||||
.draco-menu-item {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
|
||||
padding: 8px 10px; border: 0; border-radius: 10px;
|
||||
background: transparent; color: var(--ink); font: inherit; text-align: left;
|
||||
text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
|
||||
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
|
||||
.draco-compte-nom {
|
||||
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
|
||||
border-bottom: 1px solid var(--line); margin-bottom: 6px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.draco-apps-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border-radius: 10px;
|
||||
color: var(--ink); text-decoration: none;
|
||||
}
|
||||
.draco-apps-item:hover, .draco-apps-item:focus-visible { background: var(--surface-2); }
|
||||
.draco-apps-item[data-active="true"] { background: var(--surface-3); box-shadow: inset 2px 0 0 var(--agent); }
|
||||
.draco-apps-item-txt { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.draco-apps-item-nom { font-weight: 700; font-size: 14px; }
|
||||
.draco-apps-item-role { font-size: 12px; color: var(--ink-2); }
|
||||
|
||||
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
|
||||
.draco-apptabs { display: flex; gap: 6px; }
|
||||
@@ -97,8 +150,10 @@
|
||||
}
|
||||
.draco-icon-btn:hover { background: var(--surface-2); }
|
||||
.draco-avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
|
||||
background: #2f6fed; color: #fff; font-weight: 700;
|
||||
width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%;
|
||||
display: grid; place-items: center; flex: none;
|
||||
background: #2f6fed; color: #fff; font: inherit; font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
|
||||
@@ -119,9 +174,27 @@
|
||||
.draco-bar { grid-template-columns: auto auto 1fr; }
|
||||
.draco-bar-droite { justify-content: flex-end; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
/* Mobile : la barre du haut se condense ; la navigation d'apps passe
|
||||
en barre basse propre à chaque app (patron du prototype v5+). */
|
||||
.draco-apptabs, .draco-domaines-lbl { display: none; }
|
||||
.draco-bar { height: 52px; }
|
||||
@media (max-width: 900px) {
|
||||
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
|
||||
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
|
||||
avatar. Le rail, la recherche et les domaines disparaissent :
|
||||
chaque app garde ses contrôles natifs (barre
|
||||
basse, en-tête) — un seul hamburger visible, celui de l'app.
|
||||
Le changement d'app passe par le menu du ✦ (v0.1.3).
|
||||
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
|
||||
restaient affichés et la barre débordait sur téléphone.
|
||||
v0.1.4 — palier élargi de 700 à 900 px : entre les deux, la
|
||||
barre complète ne tenait pas (fenêtres réduites, tablettes,
|
||||
webviews) alors que les apps sont déjà en mise en page mobile.) */
|
||||
.draco-bar { height: 52px; grid-template-columns: auto 1fr auto; }
|
||||
.draco-bar-centre, .draco-domaines, .draco-bar .draco-icon-btn { display: none; }
|
||||
.draco-apptab { display: none; }
|
||||
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
|
||||
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
||||
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
|
||||
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
|
||||
/* 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. */
|
||||
.draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
|
||||
}
|
||||
|
||||
+505
-15
@@ -1,4 +1,14 @@
|
||||
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2)
|
||||
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
|
||||
* v0.1.3 : menu des applications ouvert par la marque ✦ ;
|
||||
* v0.1.5 : noms publics par configuration — aucun nom public d'un
|
||||
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
|
||||
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
|
||||
* la publication est séparée) : dérivation -dev par l'hôte courant,
|
||||
* menu trois points par défaut et menu compte fonctionnels, voir ci-dessous ;
|
||||
* 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 »).
|
||||
*
|
||||
* 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
|
||||
@@ -9,27 +19,217 @@
|
||||
* current: "tiktak", // app courante
|
||||
* domain: "pro", // domaine actif
|
||||
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
||||
* user: "Bruno", // nom affiché (v0.1.6) ;
|
||||
* // ou { display_name: "Bruno", username: "bruno" }
|
||||
* urls: { // bases des applications
|
||||
* obsigate: "https://og.dracodev.net/",
|
||||
* flowdeck: "https://flowdeck.dracodev.net/",
|
||||
* tiktak: "https://tiktak.dracodev.net/",
|
||||
* kanbante: "https://kanbante.dracodev.net/",
|
||||
* agent: "https://agent.dracodev.net/",
|
||||
* spark: "https://spark.dracodev.net/"
|
||||
* },
|
||||
* onAction: function (act, el) { … } // l'app relaie les actions
|
||||
* } // qu'elle ne connaît pas
|
||||
*
|
||||
* URL des applications (contrat dev/prod, v0.1.5 puis v0.1.6) —
|
||||
* résolution, dans l'ordre STRICT, pour chaque app :
|
||||
* 1. DRACO_BAR_CONFIG.urls.<app>, injectée par l'app hôte (rendu
|
||||
* serveur ou client) depuis SA configuration d'environnement —
|
||||
* explicite, donc prioritaire, même sur un hôte -dev ;
|
||||
* 2. (v0.1.6) SI l'hôte courant est un hôte -dev dracodev.net
|
||||
* (première étiquette se terminant par -dev : tiktak-dev,
|
||||
* kanbante-dev, og-dev, flowdeck-dev, agent-dev…), l'URL sœur
|
||||
* dérivée avec -dev (voir DEV_URLS). C'est le correctif du bug
|
||||
* constaté sur tiktak-dev / kanbante-dev, où les liens suivaient
|
||||
* les noms SANS -dev faute de configuration explicite ;
|
||||
* 3. le href déjà rendu dans le fragment HTML, c.-à-d. le jeton
|
||||
* {{URL_<APP>}} remplacé côté serveur par l'app hôte ;
|
||||
* 4. le défaut officiel ci-dessous (production, noms SANS suffixe).
|
||||
* Un jeton {{URL_…}} non remplacé n'est jamais une URL : il est ignoré
|
||||
* partout (config injectée comme href rendu) et ne peut pas être exposé.
|
||||
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
|
||||
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
|
||||
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
|
||||
* 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
|
||||
* rendu » comme les autres onglets, en plus d'alimenter le menu de
|
||||
* la marque et DRACO_BAR.urls.
|
||||
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
||||
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
|
||||
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
|
||||
* (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
|
||||
* ouvre le menu des applications, construit depuis les onglets, plus
|
||||
* (depuis le correctif #70) une entrée « Console Spark » vers la
|
||||
* console de l'agent commun (URL agent résolue, voir ci-dessous) ;
|
||||
* c'est le moyen de changer d'app en mobile, où les onglets sont
|
||||
* repliés (v0.1.2). Menu compte : voir ci-dessous.
|
||||
* L'action « menu » n'est PLUS émise nulle part : le bouton trois points
|
||||
* a été supprimé (#70) ; les branches onAction("menu") des hôtes
|
||||
* (TikTak/Kanbante/Flowdeck : retour false pour le menu par défaut ;
|
||||
* ObsiGate : clic #header-menu-btn) sont donc mortes et ont été
|
||||
* retirées des gabarits hôtes — le menu natif d'ObsiGate
|
||||
* (#header-menu-btn) lui-même n'est pas touché.
|
||||
*
|
||||
* Retrait du bouton trois points (correctif fusion #70) — le bouton et
|
||||
* son menu par défaut (console agent + applications) sont supprimés
|
||||
* du fragment, du JS et du CSS. La console agent reste joignable par
|
||||
* l'entrée « Console Spark » du menu de la marque (ci-dessous) et
|
||||
* par le bouton Spark (panneau), donc aucun accès n'est perdu.
|
||||
* Menu des applications (v0.1.3, étendu #70) — construit depuis les
|
||||
* onglets présents dans la barre, puis une entrée « Console Spark »
|
||||
* (logo ✦, rôle « Agent commun ») APRÈS Kanbante, vers l'URL agent
|
||||
* résolue : 1) config explicite DRACO_BAR_CONFIG.urls.agent,
|
||||
* 2) dérivation -dev agent-dev sur hôte -dev, 3) href rendu de
|
||||
* l'onglet « Console Spark » (chantier 5, data-app="agent"),
|
||||
* 4) défaut officiel prod https://agent.dracodev.net/.
|
||||
* AUCUN hôte spark-dev n'est inventé :
|
||||
* la clé spark, elle, suit toujours son propre contrat (explicite >
|
||||
* href rendu > défaut prod) et ne sert qu'au panneau Spark.
|
||||
* Focus (correctif #70) — ouverture au clavier : Entrée sur la marque
|
||||
* (clic natif du lien) ; Espace sur la marque (keydown dédié, car un
|
||||
* lien ne s'active pas à l'Espace) : ouvre le menu et met le focus
|
||||
* sur son premier item. Fermeture : Échap (focus rendu au déclencheur
|
||||
* qui avait ouvert le menu : la marque pour le menu apps, l'avatar
|
||||
* pour le menu compte), nouvel appui sur le déclencheur (le focus y
|
||||
* est déjà), choix d'un item, ou clic extérieur — ce dernier cas ne
|
||||
* déplace VOLONTAIREMENT pas le focus (l'utilisateur vient de cliquer
|
||||
* ailleurs : recherche, tâche… ; lui voler le focus casserait son
|
||||
* geste). aria-expanded est tenu à jour sur chaque déclencheur.
|
||||
* Écouteurs globaux (correctif #70, bug tiktak-dev) — les écouteurs
|
||||
* clic/Échap de la barre sont posés via EventTarget.prototype (voir
|
||||
* addGlobalListener), PAS via document.addEventListener. Cause
|
||||
* prouvée sur TikTak (app/static/js/app.js, navigation #89) : app.js
|
||||
* remplace document/window.addEventListener par un registre
|
||||
* (__tcDocWin) et chaque navigation douce appelle tcInit() puis
|
||||
* __tcTeardown(), qui SUPPRIME tous les écouteurs du registre — y
|
||||
* compris ceux de draco-barre.js (script defer, enregistré après le
|
||||
* tcInit() initial). La barre survit à la navigation (hors .layout
|
||||
* remplacé) mais ses gestionnaires sont morts : un menu ouvert avant
|
||||
* la navigation reste ouvert (Échap et clic extérieur sans effet) et
|
||||
* la marque, lien href="/", est interceptée par l'intercepteur SPA
|
||||
* brut de TikTak (__tcDocAdd, hors registre, donc survivant) comme
|
||||
* une navigation interne au lieu de basculer le menu. Les écouteurs
|
||||
* posés sur le prototype ne figurent pas dans le registre et ne sont
|
||||
* donc jamais supprimés par le teardown ; en complément, le gabarit
|
||||
* TikTak exclut désormais .draco-bar de l'intercepteur SPA, pour que
|
||||
* la marque ne déclenche plus de navigation douce. Les autres hôtes
|
||||
* (SSR, rechargement complet) ne sont pas concernés par le teardown
|
||||
* mais bénéficient du même code ; repli : si EventTarget.prototype
|
||||
* est indisponible (mini-DOM de tests), document.addEventListener
|
||||
* classique est utilisé.
|
||||
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
|
||||
* un « B » codé en dur dans le fragment, est un vrai <button>
|
||||
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
|
||||
* la première lettre du title {{UTILISATEUR}} remplacé ; sans nom connu,
|
||||
* le neutre ✦ est conservé (jamais de lettre codée en dur). Le clic
|
||||
* ouvre un petit menu affichant le nom de l'utilisateur ; les items
|
||||
* Profil / Déconnexion sont relayés à l'hôte via onAction ou, à défaut,
|
||||
* via l'événement draco:bar (détail { act: "profil" | "deconnexion" }),
|
||||
* mais le menu affiche l'identité même sans gestionnaire hôte.
|
||||
* Accessibilité : vrais <button>, aria-haspopup / aria-expanded,
|
||||
* role="menu" / "menuitem", fermeture par Échap et clic extérieur.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
var cfg = window.DRACO_BAR_CONFIG || {};
|
||||
|
||||
/* Défauts OFFICIELS (production) — les seuls noms publics présents
|
||||
* dans ce fichier, documentés comme défauts (contrat dev/prod). */
|
||||
var DEFAULT_URLS = {
|
||||
obsigate: "https://og.dracodev.net/",
|
||||
flowdeck: "https://flowdeck.dracodev.net/",
|
||||
tiktak: "https://tiktak.dracodev.net/",
|
||||
kanbante: "https://kanbante.dracodev.net/",
|
||||
agent: "https://agent.dracodev.net/",
|
||||
spark: "https://spark.dracodev.net/"
|
||||
};
|
||||
|
||||
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
|
||||
* est lui-même un hôte -dev dracodev.net (voir isDevHost). spark est
|
||||
* volontairement absent : aucun hôte spark-dev connu, aucune URL
|
||||
* inventée ; spark retombe sur href rendu puis défaut prod. */
|
||||
var DEV_URLS = {
|
||||
obsigate: "https://og-dev.dracodev.net/",
|
||||
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||
agent: "https://agent-dev.dracodev.net/"
|
||||
};
|
||||
|
||||
var APP_LABELS = {
|
||||
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante"
|
||||
};
|
||||
|
||||
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
|
||||
* se terminant par -dev ET domaine dracodev.net (un suffixe -dev sur
|
||||
* un autre domaine, ou un hôte piège du type x-dev.dracodev.net.evil,
|
||||
* ne déclenche rien). Insensible à la casse. */
|
||||
function isDevHost(hostname) {
|
||||
if (!hostname) return false;
|
||||
var h = String(hostname).toLowerCase();
|
||||
if (h.slice(-13) !== ".dracodev.net") return false;
|
||||
var first = h.split(".")[0];
|
||||
return first.length > 4 && first.slice(-4) === "-dev";
|
||||
}
|
||||
|
||||
/* Un jeton {{…}} non remplacé n'est pas une URL : l'ignorer. */
|
||||
function cleanUrl(u) {
|
||||
if (!u || typeof u !== "string") return null;
|
||||
if (u.indexOf("{{") !== -1) return null;
|
||||
if (!u.replace(/\s/g, "")) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
function withSlash(u) {
|
||||
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
|
||||
}
|
||||
|
||||
function resolveUrls() {
|
||||
var host = "";
|
||||
try { host = (window.location && window.location.hostname) || ""; } catch (e) { host = ""; }
|
||||
var dev = isDevHost(host);
|
||||
var urls = {};
|
||||
Object.keys(DEFAULT_URLS).forEach(function (app) {
|
||||
/* 1. Config explicite de l'hôte — prioritaire, même sur -dev. */
|
||||
var u = cleanUrl(cfg.urls && cfg.urls[app]);
|
||||
/* 2. Dérivation -dev par l'hôte courant (spark : pas de dérivée). */
|
||||
if (!u && dev) u = DEV_URLS[app] || null;
|
||||
/* 3. href rendu dans le fragment (jeton remplacé côté serveur). */
|
||||
if (!u) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
var href = tab ? tab.getAttribute("href") : null;
|
||||
u = cleanUrl(href);
|
||||
}
|
||||
/* 4. Défaut officiel (production). */
|
||||
if (!u) u = DEFAULT_URLS[app];
|
||||
urls[app] = withSlash(u);
|
||||
});
|
||||
return urls;
|
||||
}
|
||||
function applyUrls(urls) {
|
||||
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
|
||||
if (t.dataset.app && urls[t.dataset.app]) t.setAttribute("href", urls[t.dataset.app]);
|
||||
});
|
||||
}
|
||||
var resolvedUrls = resolveUrls();
|
||||
|
||||
function setCounts(counts) {
|
||||
Object.keys(counts || {}).forEach(function (k) {
|
||||
var b = document.querySelector('.draco-badge[data-badge="' + k + '"]');
|
||||
if (!b) return;
|
||||
b.textContent = counts[k];
|
||||
b.hidden = !(counts[k] > 0);
|
||||
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
||||
document.querySelectorAll('.draco-badge[data-badge="' + k + '"]').forEach(function (b) {
|
||||
b.textContent = counts[k];
|
||||
b.hidden = !(counts[k] > 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
function setCurrent(app) {
|
||||
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||
t.dataset.active = t.dataset.app === app ? "true" : "false";
|
||||
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
|
||||
if (t.dataset.app) t.dataset.active = t.dataset.app === app ? "true" : "false";
|
||||
});
|
||||
}
|
||||
function setDomain(dom) {
|
||||
@@ -38,18 +238,308 @@
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (ev) {
|
||||
var el = ev.target.closest("[data-act]");
|
||||
/* ── Identité / avatar (v0.1.6) ───────────────────────────────────
|
||||
* Nom : cfg.user (chaîne, ou objet display_name / name / username),
|
||||
* sinon le title de l'avatar quand le jeton {{UTILISATEUR}} a bien été
|
||||
* remplacé côté serveur. L'initiale en découle ; sans nom, le neutre
|
||||
* ✦ du fragment est conservé — jamais de lettre codée en dur. */
|
||||
function userName() {
|
||||
var u = cfg.user;
|
||||
if (typeof u === "string" && u.replace(/\s/g, "")) return u.trim();
|
||||
if (u && typeof u === "object") {
|
||||
var n = u.display_name || u.name || u.username;
|
||||
if (n && String(n).replace(/\s/g, "")) return String(n).trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function titleName() {
|
||||
var av = document.querySelector(".draco-avatar");
|
||||
var t = av ? av.getAttribute("title") : null;
|
||||
if (t && t.indexOf("{{") === -1 && t.replace(/\s/g, "")) return t.trim();
|
||||
return null;
|
||||
}
|
||||
function displayName() { return userName() || titleName(); }
|
||||
function syncAvatar() {
|
||||
var av = document.querySelector(".draco-avatar");
|
||||
if (!av) return;
|
||||
/* Compatibilité avec un fragment vendangé antérieur (avatar <span>
|
||||
* sans data-act) : le rendre déclencheur du menu compte. */
|
||||
if (!av.getAttribute("data-act")) av.setAttribute("data-act", "compte");
|
||||
if (!av.getAttribute("aria-haspopup")) av.setAttribute("aria-haspopup", "menu");
|
||||
var name = displayName();
|
||||
if (name) {
|
||||
av.textContent = name.charAt(0).toUpperCase();
|
||||
av.setAttribute("title", name);
|
||||
av.setAttribute("aria-label", "Compte : " + name);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Menus déroulants (apps, compte) ───────────────────────────────
|
||||
* Un seul menu ouvert à la fois ; fermeture par clic extérieur,
|
||||
* Échap, ou choix d'un item. aria-expanded tenu à jour sur chaque
|
||||
* déclencheur. Les href des menus sont (re)synchronisés avec
|
||||
* resolvedUrls à chaque ouverture, pour que setUrls et la dérivation
|
||||
* -dev s'y reflètent même si le menu a été construit avant. */
|
||||
var bar = document.querySelector(".draco-bar");
|
||||
var brand = bar ? bar.querySelector(".draco-brand") : null;
|
||||
var avatarBtn = document.querySelector(".draco-avatar");
|
||||
var appsMenu = null, compteMenu = null;
|
||||
/* Déclencheur du menu ouvert en dernier (marque ou avatar) : Échap
|
||||
* lui rend le focus à la fermeture (correctif #70). */
|
||||
var lastOpener = null;
|
||||
|
||||
function setExpanded(btn, open) {
|
||||
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
function refreshMenuHrefs(menu) {
|
||||
if (!menu) return;
|
||||
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
|
||||
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
|
||||
});
|
||||
}
|
||||
function togglePopup(menu, btn, force) {
|
||||
if (!menu) return;
|
||||
var open = typeof force === "boolean" ? force : menu.hidden;
|
||||
menu.hidden = !open;
|
||||
setExpanded(btn, open);
|
||||
}
|
||||
function closeAllPopups(returnFocus) {
|
||||
var anyOpen = (appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden);
|
||||
if (appsMenu) togglePopup(appsMenu, brand, false);
|
||||
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
|
||||
/* Échap seulement : le clic extérieur ne déplace pas le focus
|
||||
* (voir l'en-tête, « Focus »). */
|
||||
if (returnFocus && anyOpen && lastOpener && typeof lastOpener.focus === "function") lastOpener.focus();
|
||||
}
|
||||
function tabLabel(app) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
var fort = tab ? tab.querySelector(".draco-apptab-txt strong") : null;
|
||||
return fort ? fort.textContent : (APP_LABELS[app] || app);
|
||||
}
|
||||
function tabRole(app) {
|
||||
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
|
||||
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
|
||||
return petit ? petit.textContent : "";
|
||||
}
|
||||
/* Menu des applications (v0.1.3) — construit depuis les onglets
|
||||
* présents dans la barre : mêmes liens, mêmes icônes, mêmes badges.
|
||||
* Ouvert par la marque ✦ (data-act="apps"), fermé par un clic
|
||||
* extérieur, Échap, ou le choix d'une application (la navigation
|
||||
* suit son cours). */
|
||||
function buildAppsMenu() {
|
||||
if (!bar || !brand || appsMenu) return;
|
||||
appsMenu = document.createElement("div");
|
||||
appsMenu.className = "draco-apps-menu";
|
||||
appsMenu.hidden = true;
|
||||
appsMenu.setAttribute("role", "menu");
|
||||
appsMenu.setAttribute("aria-label", "Applications");
|
||||
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||
/* Chantier 5 : l'onglet agent (« Console Spark ») n'est PAS
|
||||
* repris ici — l'entrée dédiée ci-dessous le représente déjà
|
||||
* (après Kanbante, avec séparateur) ; sans ce saut, le menu
|
||||
* listerait « Console Spark » deux fois. */
|
||||
if (t.dataset.app === "agent") return;
|
||||
var a = document.createElement("a");
|
||||
a.className = "draco-apps-item";
|
||||
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
|
||||
a.dataset.app = t.dataset.app;
|
||||
a.setAttribute("role", "menuitem");
|
||||
var ico = t.querySelector(".draco-apptab-ico");
|
||||
if (ico) a.appendChild(ico.cloneNode(true));
|
||||
var txt = document.createElement("span");
|
||||
txt.className = "draco-apps-item-txt";
|
||||
var fort = t.querySelector(".draco-apptab-txt strong");
|
||||
var petit = t.querySelector(".draco-apptab-txt small");
|
||||
var nom = document.createElement("span");
|
||||
nom.className = "draco-apps-item-nom";
|
||||
nom.textContent = fort ? fort.textContent : t.dataset.app;
|
||||
txt.appendChild(nom);
|
||||
if (petit) {
|
||||
var role = document.createElement("span");
|
||||
role.className = "draco-apps-item-role";
|
||||
role.textContent = petit.textContent;
|
||||
txt.appendChild(role);
|
||||
}
|
||||
a.appendChild(txt);
|
||||
var badge = t.querySelector(".draco-badge");
|
||||
if (badge) a.appendChild(badge.cloneNode(true));
|
||||
appsMenu.appendChild(a);
|
||||
});
|
||||
/* Entrée « Console Spark » (#70) : après Kanbante, 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. */
|
||||
var sep = document.createElement("div");
|
||||
sep.className = "draco-menu-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
appsMenu.appendChild(sep);
|
||||
var sparkItem = document.createElement("a");
|
||||
sparkItem.className = "draco-apps-item";
|
||||
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 = "✦";
|
||||
sparkItem.appendChild(sparkIco);
|
||||
var sparkTxt = document.createElement("span");
|
||||
sparkTxt.className = "draco-apps-item-txt";
|
||||
var sparkNom = document.createElement("span");
|
||||
sparkNom.className = "draco-apps-item-nom";
|
||||
sparkNom.textContent = "Console Spark";
|
||||
sparkTxt.appendChild(sparkNom);
|
||||
var sparkRole = document.createElement("span");
|
||||
sparkRole.className = "draco-apps-item-role";
|
||||
sparkRole.textContent = "Agent commun";
|
||||
sparkTxt.appendChild(sparkRole);
|
||||
sparkItem.appendChild(sparkTxt);
|
||||
appsMenu.appendChild(sparkItem);
|
||||
bar.appendChild(appsMenu);
|
||||
}
|
||||
function toggleAppsMenu(force) {
|
||||
buildAppsMenu();
|
||||
if (!appsMenu) return;
|
||||
if (force !== false) refreshMenuHrefs(appsMenu);
|
||||
togglePopup(appsMenu, brand, force);
|
||||
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). */
|
||||
function buildCompteMenu() {
|
||||
if (!bar || !avatarBtn || compteMenu) return;
|
||||
compteMenu = document.createElement("div");
|
||||
compteMenu.className = "draco-compte-menu";
|
||||
compteMenu.hidden = true;
|
||||
compteMenu.setAttribute("role", "menu");
|
||||
compteMenu.setAttribute("aria-label", "Compte");
|
||||
var head = document.createElement("div");
|
||||
head.className = "draco-compte-nom";
|
||||
compteMenu.appendChild(head);
|
||||
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "draco-menu-item";
|
||||
b.setAttribute("data-compte-act", pair[0]);
|
||||
b.setAttribute("role", "menuitem");
|
||||
b.textContent = pair[1];
|
||||
compteMenu.appendChild(b);
|
||||
});
|
||||
bar.appendChild(compteMenu);
|
||||
}
|
||||
function refreshCompteMenu() {
|
||||
if (!compteMenu) return;
|
||||
var head = compteMenu.querySelector(".draco-compte-nom");
|
||||
var name = displayName();
|
||||
if (head) head.textContent = name || "Compte";
|
||||
}
|
||||
function toggleCompteMenu(force) {
|
||||
buildCompteMenu();
|
||||
if (!compteMenu) return;
|
||||
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
|
||||
togglePopup(compteMenu, avatarBtn, force);
|
||||
if (!compteMenu.hidden) lastOpener = avatarBtn;
|
||||
}
|
||||
|
||||
/* Relais d'une action vers l'hôte : onAction si présent, sinon
|
||||
* événement draco:bar (contrat historique des actions relayées). */
|
||||
function relay(act, el, ev) {
|
||||
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||
}
|
||||
|
||||
/* Écouteurs globaux posés sur le prototype EventTarget quand il
|
||||
* existe, pour survivre au registre/teardown d'un hôte SPA (TikTak,
|
||||
* voir l'en-tête) ; sinon document.addEventListener classique. */
|
||||
function addGlobalListener(type, fn) {
|
||||
try {
|
||||
if (typeof EventTarget !== "undefined" && EventTarget.prototype &&
|
||||
EventTarget.prototype.addEventListener) {
|
||||
EventTarget.prototype.addEventListener.call(document, type, fn, false);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* repli ci-dessous */ }
|
||||
document.addEventListener(type, fn);
|
||||
}
|
||||
|
||||
addGlobalListener("click", function (ev) {
|
||||
/* Items du menu compte : relais + fermeture, jamais de navigation. */
|
||||
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
|
||||
if (compteItem) {
|
||||
relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
|
||||
toggleCompteMenu(false);
|
||||
return;
|
||||
}
|
||||
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
|
||||
if (el && el.dataset.act === "apps") {
|
||||
ev.preventDefault();
|
||||
if (compteMenu) toggleCompteMenu(false);
|
||||
toggleAppsMenu();
|
||||
return;
|
||||
}
|
||||
if (el && el.dataset.act === "compte") {
|
||||
if (appsMenu) toggleAppsMenu(false);
|
||||
toggleCompteMenu();
|
||||
return;
|
||||
}
|
||||
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
|
||||
* sur son déclencheur déjà traité ci-dessus). */
|
||||
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
|
||||
ev.target.closest(".draco-compte-menu")) : null;
|
||||
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
|
||||
closeAllPopups(false);
|
||||
}
|
||||
if (!el) return;
|
||||
var act = el.dataset.act;
|
||||
if (act === "domaine") setDomain(el.dataset.dom);
|
||||
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||
relay(act, el, ev);
|
||||
});
|
||||
|
||||
/* API publique : l'app met les compteurs à jour sans recharger. */
|
||||
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
|
||||
addGlobalListener("keydown", function (ev) {
|
||||
if (ev.key === "Escape") { closeAllPopups(true); return; }
|
||||
/* Espace sur la marque : un lien ne s'active pas à l'Espace, donc
|
||||
* ouverture explicite + focus sur le premier item du menu (#70).
|
||||
* Entrée : clic natif du lien, déjà géré par le gestionnaire clic
|
||||
* ci-dessus (pas de double bascule ici). */
|
||||
if ((ev.key === " " || ev.key === "Spacebar") && ev.target && ev.target.closest) {
|
||||
var brandEl = ev.target.closest('[data-act="apps"]');
|
||||
if (brandEl) {
|
||||
if (ev.preventDefault) ev.preventDefault();
|
||||
toggleAppsMenu();
|
||||
if (appsMenu && !appsMenu.hidden) {
|
||||
var first = appsMenu.querySelector(".draco-apps-item");
|
||||
if (first && typeof first.focus === "function") first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* API publique : l'app met les compteurs à jour sans recharger.
|
||||
* setUrls(urls) fusionne de nouvelles bases (même contrat que
|
||||
* DRACO_BAR_CONFIG.urls — donc prioritaires sur la dérivation -dev)
|
||||
* et les ré-applique aux onglets et aux menus ; DRACO_BAR.urls expose
|
||||
* les bases résolues (défauts inclus), pour le panneau Spark et tout
|
||||
* autre consommateur de l'hôte. */
|
||||
window.DRACO_BAR = {
|
||||
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
|
||||
urls: resolvedUrls,
|
||||
setUrls: function (urls) {
|
||||
cfg.urls = Object.assign({}, cfg.urls, urls || {});
|
||||
resolvedUrls = resolveUrls();
|
||||
window.DRACO_BAR.urls = resolvedUrls;
|
||||
applyUrls(resolvedUrls);
|
||||
}
|
||||
};
|
||||
|
||||
applyUrls(resolvedUrls);
|
||||
buildAppsMenu();
|
||||
buildCompteMenu();
|
||||
syncAvatar();
|
||||
refreshCompteMenu();
|
||||
if (cfg.current) setCurrent(cfg.current);
|
||||
if (cfg.domain) setDomain(cfg.domain);
|
||||
if (cfg.counts) setCounts(cfg.counts);
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
kanbante: "#0d9488",
|
||||
};
|
||||
|
||||
/* Base TikTak fournie par l'hôte via window.DRACO_URLS (contrat
|
||||
* d'injection dracodev-commons, #223) ; repli = défaut officiel. */
|
||||
function baseTikTak() {
|
||||
return (window.DRACO_URLS && window.DRACO_URLS.tiktak) || "https://tiktak.dracodev.net";
|
||||
}
|
||||
|
||||
function t(key, fallback) {
|
||||
try {
|
||||
var v = window.t && window.t(key);
|
||||
@@ -92,7 +98,7 @@
|
||||
t("draco.links.projected", "Tâche TikTak projetée")));
|
||||
var ouvrir = el("a", "btn-action draco-liens-ouvrir",
|
||||
t("draco.links.openInTikTak", "Ouvrir dans TikTak"));
|
||||
ouvrir.href = "https://tiktak.dracodev.net/?task=" + tid[1];
|
||||
ouvrir.href = baseTikTak() + "/?task=" + tid[1];
|
||||
ouvrir.target = "_blank";
|
||||
ouvrir.rel = "noopener";
|
||||
bandeau.appendChild(ouvrir);
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
|
||||
Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
|
||||
le JS le cale sur le thème de l'app hôte (html[data-theme], repli
|
||||
prefers-color-scheme) et suit les bascules en direct. Toutes les
|
||||
couleurs passent par les variables ci-dessous — en-tête et fenêtre
|
||||
partagent donc toujours le même mode. */
|
||||
.draco-panneau {
|
||||
--p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
|
||||
--p-texte: #242624; --p-doux: #6c7268; --p-faint: #8a8f84;
|
||||
--p-chip: #f4f5f1; --p-chip-bord: #d6d8d1; --p-chip-survol: #e9ebe4;
|
||||
--p-agent: #eef0ea; --p-champ: #ffffff; --p-code: #e8eae3;
|
||||
--p-appro-fond: #fdf6ec; --p-danger-fond: #f6e3de; --p-danger: #a03a28;
|
||||
--p-accent: #7c5cd6; --p-lien: #5b3df0;
|
||||
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 96vw;
|
||||
background: var(--p-fond); color: var(--p-texte); z-index: 400;
|
||||
box-shadow: -12px 0 36px rgb(0 0 0 / .22);
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateX(105%); transition: transform .28s ease;
|
||||
font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
|
||||
}
|
||||
.draco-panneau[data-draco-theme="dark"] {
|
||||
--p-fond: #17181a; --p-surface: #1f2023; --p-bord: #33353a;
|
||||
--p-texte: #eceeeb; --p-doux: #a6aba0; --p-faint: #83887e;
|
||||
--p-chip: #26272b; --p-chip-bord: #3a3c41; --p-chip-survol: #2e3036;
|
||||
--p-agent: #26272b; --p-champ: #1f2023; --p-code: #2c2e33;
|
||||
--p-appro-fond: #2a2118; --p-danger-fond: #40241f; --p-danger: #e8a08d;
|
||||
--p-lien: #b39df5;
|
||||
}
|
||||
.draco-panneau.open { transform: translateX(0); }
|
||||
.draco-poignee {
|
||||
position: absolute; left: 0; top: 0; bottom: 0; width: 8px;
|
||||
cursor: ew-resize; z-index: 5; touch-action: none;
|
||||
}
|
||||
.draco-poignee:hover { background: color-mix(in srgb, var(--p-accent) 35%, transparent); }
|
||||
@media (max-width: 700px) { .draco-poignee { display: none; } }
|
||||
.draco-panneau-hd {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
}
|
||||
.draco-panneau-titres { min-width: 0; flex: 1; }
|
||||
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.draco-panneau-hd .soustitre {
|
||||
font-size: 12px; color: var(--p-doux); display: flex; align-items: center; gap: 6px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.draco-panneau-hd .soustitre .point { width: 8px; height: 8px; border-radius: 50%; background: #b9bdb2; flex: none; }
|
||||
.draco-panneau-hd .soustitre.ok .point { background: #2ea36b; }
|
||||
.draco-panneau-hd .soustitre.err .point { background: #cf4b32; }
|
||||
.draco-panneau-hd .fermer {
|
||||
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
|
||||
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px; flex: none;
|
||||
}
|
||||
/* Barre des discussions (façon Muse) */
|
||||
.draco-disc-bar {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
}
|
||||
.draco-disc-bar .d-titre {
|
||||
flex: 1; font-weight: 600; font-size: 13px; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.draco-disc-bar .d-btn {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||
border-radius: 999px; padding: 4px 10px; font: 600 12px system-ui; cursor: pointer; flex: none;
|
||||
}
|
||||
.draco-disc-bar .d-btn:hover { background: var(--p-chip-survol); }
|
||||
.draco-discussions {
|
||||
border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||
max-height: 46%; overflow-y: auto; padding: 8px 12px;
|
||||
}
|
||||
.draco-discussions .d-section {
|
||||
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .5px; color: var(--p-doux); margin: 8px 2px 4px;
|
||||
}
|
||||
.draco-discussions .d-section:first-child { margin-top: 0; }
|
||||
.draco-discussions .d-item {
|
||||
border: 1px solid transparent; border-radius: 9px; padding: 6px 8px; cursor: pointer;
|
||||
}
|
||||
.draco-discussions .d-item:hover { background: var(--p-chip); }
|
||||
.draco-discussions .d-item.actif { border-color: var(--p-accent); background: var(--p-chip); }
|
||||
.draco-discussions .d-meta { font-weight: 600; font-size: 13px; }
|
||||
.draco-discussions .d-sous { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-discussions .d-actions { display: flex; gap: 6px; margin-top: 5px; }
|
||||
.draco-discussions .d-actions button {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-surface); color: var(--p-texte);
|
||||
border-radius: 7px; padding: 3px 9px; font: 600 11.5px system-ui; cursor: pointer;
|
||||
}
|
||||
.draco-discussions .d-actions button.danger { color: var(--p-danger); border-color: var(--p-danger-fond); }
|
||||
.draco-discussions .d-vide, .draco-discussions .d-chargement {
|
||||
font-size: 12.5px; color: var(--p-doux); padding: 2px 8px 6px;
|
||||
}
|
||||
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface); }
|
||||
.draco-panneau-outils button {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
|
||||
}
|
||||
.draco-panneau-outils button:hover { background: var(--p-chip-survol); }
|
||||
.draco-appro {
|
||||
align-self: stretch; background: var(--p-appro-fond); border: 1.6px solid #e8a13c;
|
||||
border-radius: 12px; padding: 10px 12px;
|
||||
}
|
||||
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
|
||||
.draco-panneau[data-draco-theme="dark"] .draco-appro .titre { color: #e8bc7a; }
|
||||
.draco-appro .resume { margin: 5px 0 8px; }
|
||||
.draco-appro .btns { display: flex; gap: 7px; }
|
||||
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
|
||||
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
|
||||
.draco-auj { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 6px 0 4px; }
|
||||
.draco-auj h5:first-child { margin-top: 0; }
|
||||
.draco-auj ul { margin: 0; padding-left: 17px; }
|
||||
.draco-auj .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
|
||||
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
|
||||
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
|
||||
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
|
||||
.draco-panneau-ft { padding: 10px 12px 11px; background: var(--p-fond); }
|
||||
/* Puces de suggestions, comme la console : au-dessus du composer. */
|
||||
.draco-puces {
|
||||
display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; padding: 0 2px 8px;
|
||||
}
|
||||
.draco-puces button {
|
||||
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
|
||||
}
|
||||
.draco-puces button:hover { background: var(--p-chip-survol); }
|
||||
/* ── Composer façon console (pilule) ─────────────────────────
|
||||
* +, textarea auto-extensible, micro ou envoi rond bleu ↑ ;
|
||||
* pendant la dictée, la rangée d'enregistrement remplace la saisie. */
|
||||
.draco-composer {
|
||||
display: flex; flex-direction: column; align-items: stretch; gap: 6px;
|
||||
background: var(--p-surface); border: 1px solid var(--p-bord);
|
||||
border-radius: 24px; padding: 8px 10px;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / .05);
|
||||
}
|
||||
.draco-composer [hidden] { display: none !important; }
|
||||
.draco-composer-ligne { display: flex; align-items: flex-end; gap: 6px; }
|
||||
.draco-composer textarea {
|
||||
flex: 1; border: 0; background: none; resize: none; outline: none;
|
||||
padding: 8px 2px; max-height: 152px; overflow-y: auto; line-height: 1.45;
|
||||
min-height: calc(1.45em + 16px); font: inherit; color: var(--p-texte);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.draco-composer textarea::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
||||
.draco-composer .composer-btn {
|
||||
width: 36px; height: 36px; border-radius: 50%; border: 0; flex: none;
|
||||
background: var(--p-chip); color: var(--p-texte); font-size: 17px;
|
||||
display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
|
||||
}
|
||||
.draco-composer .composer-btn:hover { background: var(--p-chip-survol); }
|
||||
.draco-composer .composer-btn.danger { color: var(--p-danger); }
|
||||
.draco-composer .composer-envoi {
|
||||
width: 36px; height: 36px; border-radius: 50%; border: 0; flex: none;
|
||||
background: #2f6fed; color: #fff; font-size: 17px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
|
||||
}
|
||||
.draco-composer .composer-envoi:disabled { opacity: .5; }
|
||||
.draco-composer .composer-btn .ic, .draco-composer .composer-envoi .ic { width: 19px; height: 19px; }
|
||||
.draco-composer-pieces { display: flex; flex-wrap: wrap; gap: 8px; padding: 2px 2px 0; }
|
||||
.draco-composer-pieces .piece-carte {
|
||||
position: relative; display: flex; align-items: center; gap: 8px;
|
||||
background: var(--p-chip); border: 1px solid var(--p-chip-bord);
|
||||
border-radius: 12px; padding: 5px 9px 5px 5px; max-width: 230px;
|
||||
}
|
||||
.draco-composer-pieces .piece-carte img { width: 42px; height: 42px; object-fit: cover; border-radius: 8px; display: block; }
|
||||
.draco-composer-pieces .piece-nom { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.draco-composer-pieces .piece-doc-ext {
|
||||
width: 42px; height: 42px; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--p-chip); border: 1px solid var(--p-chip-bord); border-radius: 8px;
|
||||
}
|
||||
.draco-composer-pieces .piece-ext { font-weight: 700; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; opacity: .75; }
|
||||
.draco-composer-pieces .piece-retirer {
|
||||
position: absolute; top: -7px; right: -7px; width: 20px; height: 20px;
|
||||
border-radius: 50%; border: 0; background: var(--p-texte); color: var(--p-fond);
|
||||
font-size: 10px; line-height: 1; padding: 0; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.draco-composer-enreg { display: flex; align-items: center; gap: 10px; padding: 0 2px; }
|
||||
.draco-composer-enreg .enreg-temps { font-variant-numeric: tabular-nums; font-weight: 700; min-width: 36px; }
|
||||
.draco-composer-enreg .enreg-texte {
|
||||
flex: 1; min-width: 0; color: var(--p-doux); font-size: 13px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.draco-composer-enreg .enreg-onde { display: flex; align-items: center; gap: 3px; height: 26px; flex: none; }
|
||||
.draco-composer-enreg .enreg-onde span { width: 4px; height: 30%; border-radius: 2px; background: #2f6fed; }
|
||||
.draco-composer.en-cours .enreg-onde span { animation: onde 1.05s ease-in-out infinite; }
|
||||
.draco-composer-enreg .enreg-onde span:nth-child(3n+2) { animation-delay: .14s; }
|
||||
.draco-composer-enreg .enreg-onde span:nth-child(3n) { animation-delay: .28s; }
|
||||
@keyframes onde { 0%, 100% { height: 22%; } 50% { height: 96%; } }
|
||||
/* Pièces jointes dans une bulle utilisateur */
|
||||
.draco-msg-pieces { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }
|
||||
.draco-msg-pieces img { max-width: 150px; max-height: 120px; border-radius: 10px; display: block; }
|
||||
.draco-msg-pieces .piece-fichier {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
background: var(--p-chip); border: 1px solid var(--p-chip-bord); color: var(--p-texte);
|
||||
border-radius: 9px; padding: 6px 9px; font-size: 12px;
|
||||
}
|
||||
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
|
||||
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
||||
.draco-missions select, .draco-missions input, .draco-missions textarea {
|
||||
width: 100%; box-sizing: border-box; border: 1px solid var(--p-chip-bord); border-radius: 8px;
|
||||
padding: 7px 9px; font: inherit; font-size: 13px; background: var(--p-champ); color: var(--p-texte);
|
||||
}
|
||||
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
|
||||
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
|
||||
.draco-missions .btns button.go { background: var(--p-accent); color: #fff; }
|
||||
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.draco-missions .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||
.draco-missions .m-item { border: 1px solid var(--p-bord); border-radius: 10px; padding: 8px 10px; }
|
||||
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
|
||||
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: var(--p-doux); white-space: nowrap; }
|
||||
.draco-missions .m-statut.st-succeeded { color: #2ea36b; }
|
||||
.draco-missions .m-statut.st-failed { color: #cf4b32; }
|
||||
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
|
||||
.draco-missions .m-meta { font-size: 12px; color: var(--p-doux); }
|
||||
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
|
||||
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: var(--p-danger-fond); color: var(--p-danger); }
|
||||
/* ── Rendu Markdown (HTML produit par le service) ──────────── */
|
||||
.draco-msg.md, .draco-missions .m-rapport.md { white-space: normal; }
|
||||
.draco-msg.md > *:first-child, .m-rapport.md > *:first-child { margin-top: 0; }
|
||||
.draco-msg.md > *:last-child, .m-rapport.md > *:last-child { margin-bottom: 0; }
|
||||
.draco-msg.md p, .m-rapport.md p { margin: 6px 0; }
|
||||
.draco-msg.md h1, .draco-msg.md h2, .draco-msg.md h3,
|
||||
.draco-msg.md h4, .draco-msg.md h5, .draco-msg.md h6 { margin: 10px 0 4px; line-height: 1.3; }
|
||||
.draco-msg.md h1 { font-size: 16.5px; } .draco-msg.md h2 { font-size: 15.5px; }
|
||||
.draco-msg.md h3 { font-size: 14.5px; } .draco-msg.md h4,
|
||||
.draco-msg.md h5, .draco-msg.md h6 { font-size: 13.5px; }
|
||||
.draco-msg.md ul, .draco-msg.md ol, .m-rapport.md ul, .m-rapport.md ol { margin: 6px 0; padding-left: 20px; }
|
||||
.draco-msg.md li, .m-rapport.md li { margin: 2px 0; }
|
||||
.draco-msg.md code, .m-rapport.md code {
|
||||
background: var(--p-code); border-radius: 5px; padding: 1px 5px;
|
||||
font: 12.5px ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
.draco-msg.md pre, .m-rapport.md pre {
|
||||
background: var(--p-code); border-radius: 9px; padding: 9px 11px;
|
||||
overflow-x: auto; margin: 7px 0;
|
||||
}
|
||||
.draco-msg.md pre code, .m-rapport.md pre code { background: none; padding: 0; }
|
||||
.draco-msg.md blockquote, .m-rapport.md blockquote {
|
||||
border-left: 3px solid var(--p-accent); margin: 7px 0; padding: 2px 0 2px 11px;
|
||||
color: var(--p-doux);
|
||||
}
|
||||
.draco-msg.md blockquote.callout, .m-rapport.md blockquote.callout {
|
||||
border-radius: 0 8px 8px 0; padding: 6px 11px; color: var(--p-texte);
|
||||
background: var(--p-chip);
|
||||
}
|
||||
.draco-msg.md blockquote.callout-warning { border-left-color: #e8a13c; }
|
||||
.draco-msg.md blockquote.callout-danger { border-left-color: #cf4b32; }
|
||||
.draco-msg.md blockquote.callout-tip { border-left-color: #2ea36b; }
|
||||
.draco-msg.md a, .m-rapport.md a { color: var(--p-lien); }
|
||||
.draco-msg.md table, .m-rapport.md table {
|
||||
border-collapse: collapse; margin: 7px 0; font-size: 12.5px;
|
||||
display: block; overflow-x: auto; max-width: 100%;
|
||||
}
|
||||
.draco-msg.md th, .draco-msg.md td, .m-rapport.md th, .m-rapport.md td {
|
||||
border: 1px solid var(--p-bord); padding: 4px 8px; text-align: left;
|
||||
}
|
||||
.draco-msg.md hr, .m-rapport.md hr { border: 0; border-top: 1px solid var(--p-bord); margin: 9px 0; }
|
||||
.draco-msg.md input[type="checkbox"], .m-rapport.md input[type="checkbox"] { margin-right: 5px; }
|
||||
File diff suppressed because it is too large
Load Diff
+52
-16
@@ -10,7 +10,7 @@
|
||||
name="description"
|
||||
content="Porte d'entrée web pour vos vaults Obsidian - Accédez, naviguez et recherchez dans toutes vos notes"
|
||||
/>
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta name="theme-color" content="#7c5cd6" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
@@ -54,14 +54,14 @@
|
||||
#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%, #1b2030 0%, #0e1117 60%);
|
||||
color: #e6e8ee; font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
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: #171c26; border: 1px solid #2a3242; border-radius: 18px;
|
||||
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; }
|
||||
@@ -76,6 +76,8 @@
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<link rel="stylesheet" href="/static/draco-barre.css" />
|
||||
<link rel="stylesheet" href="/static/draco-alertes.css" />
|
||||
<link rel="stylesheet" href="/static/draco-panneau.css" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
|
||||
@@ -331,14 +333,18 @@
|
||||
|
||||
<!-- Main Application (wrapped for show/hide) -->
|
||||
<!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1).
|
||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.0
|
||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.6
|
||||
(ui/barre-apps/) : ne pas modifier la structure ici ; toute
|
||||
évolution passe par une nouvelle version de commons.
|
||||
CSS/JS : /static/draco-barre.css, /static/draco-barre.js. -->
|
||||
CSS/JS : /static/draco-barre.css, /static/draco-barre.js.
|
||||
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 ;
|
||||
l'entrée « Console Spark » du menu de la marque est conservée. -->
|
||||
<header class="draco-bar" data-current="obsigate">
|
||||
<div class="draco-bar-zone draco-bar-gauche">
|
||||
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
||||
<a class="draco-brand" href="/" aria-label="dracodev">
|
||||
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
|
||||
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
|
||||
</a>
|
||||
<nav class="draco-apptabs" aria-label="Applications">
|
||||
@@ -360,6 +366,10 @@
|
||||
<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="agent" href="https://agent.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
|
||||
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="draco-bar-zone draco-bar-centre">
|
||||
@@ -385,8 +395,7 @@
|
||||
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
||||
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
|
||||
<span class="draco-avatar" id="draco-avatar" title="">✦</span>
|
||||
<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>
|
||||
@@ -394,8 +403,19 @@
|
||||
/* Configuration ObsiGate de la barre commune (Fusion #215).
|
||||
Phase 1 : navigation entre apps et domaine mémorisé
|
||||
localement ; la recherche focus la recherche native
|
||||
d'ObsiGate ; Alertes unifiées et Spark arrivent avec la
|
||||
fédération / l'agent commun (phases suivantes). */
|
||||
d'ObsiGate ; Alertes unifiées : panneau draco-alertes.js
|
||||
(Fusion #69, agrégation à la demande, pas une fédération
|
||||
temps réel) ; Spark via le panneau commun. */
|
||||
/* #223 : les href des onglets portent les défauts officiels ;
|
||||
l'hôte les réécrit depuis window.DRACO_URLS (injecté par le
|
||||
backend depuis sa configuration) quand il est présent. */
|
||||
(function () {
|
||||
if (!window.DRACO_URLS) return;
|
||||
document.querySelectorAll(".draco-apptab").forEach(function (a) {
|
||||
var u = window.DRACO_URLS[a.dataset.app];
|
||||
if (u) a.href = u.replace(/\/$/, "") + "/";
|
||||
});
|
||||
})();
|
||||
window.DRACO_BAR_CONFIG = {
|
||||
current: "obsigate",
|
||||
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
||||
@@ -409,11 +429,25 @@
|
||||
setTimeout(function () { d.remove(); }, 3400);
|
||||
};
|
||||
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
|
||||
if (act === "rail") { var b = document.getElementById("sidebar-toggle-btn"); if (b) b.click(); return; }
|
||||
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
|
||||
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; }
|
||||
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
|
||||
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
|
||||
if (act === "rail") {
|
||||
// Bureau : afficher/masquer la barre latérale.
|
||||
// Mobile (≤ 768 px, point de bascule d'ObsiGate) :
|
||||
// le vrai hamburger, qui ouvre le tiroir — avant
|
||||
// ce correctif (BUG-118), le rail cliquait le
|
||||
// bouton bureau et ne faisait rien sur téléphone.
|
||||
var mobile = window.matchMedia("(max-width: 768px)").matches;
|
||||
var b = document.getElementById(mobile ? "hamburger-btn" : "sidebar-toggle-btn");
|
||||
if (b) b.click();
|
||||
return;
|
||||
}
|
||||
if (act === "palette") { location.href = "/recherche"; 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") {
|
||||
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
||||
return;
|
||||
}
|
||||
if (act === "rappel-entete") {
|
||||
var bar = document.querySelector(".draco-bar");
|
||||
if (bar) bar.style.display = "";
|
||||
@@ -6040,7 +6074,9 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/draco-alertes.js"></script>
|
||||
<script defer src="/static/draco-barre.js"></script>
|
||||
<script defer src="/static/draco-liens.js"></script>
|
||||
<script defer src="/static/draco-panneau.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,514 @@
|
||||
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Contrat d'API (audité, rien d'inventé) :
|
||||
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8
|
||||
* -> { results: [{ vault, path, title, snippet }] }
|
||||
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
|
||||
* - Flowdeck : GET {base}/api/search?q=
|
||||
* -> { pages: [{ title, excerpt, subtitle, url: "/pages/<id>" }],
|
||||
* collections: [{ title, subtitle, url: "/db/<id>" }] }
|
||||
* - TikTak : GET {base}/api/search?q=
|
||||
* -> { tasks: [{ id, title, description, status, due_at }], ... }
|
||||
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search).
|
||||
* - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison)
|
||||
* -> { 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).
|
||||
*
|
||||
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
|
||||
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
|
||||
* hôte de la requête > dérivation > défaut officiel) ;
|
||||
* 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ;
|
||||
* 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ;
|
||||
* 4. href des onglets .draco-apptab rendus ;
|
||||
* 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev).
|
||||
*
|
||||
* 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 cette
|
||||
* livraison). Chaque sœur n'autorise en CORS que les origines sœurs
|
||||
* (GET sur l'endpoint de recherche uniquement, pas de "*").
|
||||
* Une sœur injoignable / non autorisée ne casse pas les autres :
|
||||
* sa section affiche l'erreur + un lien vers sa recherche native.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
/* globalThis : le fichier est chargé comme script classique dans les
|
||||
* navigateurs, requis en CJS par les tests TikTak/Kanbante/Flowdeck,
|
||||
* et importé en ESM par le test ObsiGate (frontend/js est "type":
|
||||
* "module") — où `this` de niveau supérieur vaut undefined. */
|
||||
|
||||
var PROD = {
|
||||
obsigate: "https://og.dracodev.net/",
|
||||
flowdeck: "https://flowdeck.dracodev.net/",
|
||||
tiktak: "https://tiktak.dracodev.net/",
|
||||
kanbante: "https://kanbante.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/",
|
||||
};
|
||||
var APPS = [
|
||||
{ key: "obsigate", label: "ObsiGate" },
|
||||
{ key: "flowdeck", label: "Flowdeck" },
|
||||
{ key: "tiktak", label: "TikTak" },
|
||||
{ key: "kanbante", label: "Kanbante" },
|
||||
];
|
||||
var COLUMN_LABELS = {
|
||||
backlog: "Backlog",
|
||||
in_progress: "En cours",
|
||||
qa: "QA",
|
||||
review: "Relecture",
|
||||
todo: "À faire",
|
||||
done: "Terminé",
|
||||
};
|
||||
var TIMEOUT_MS = 8000;
|
||||
var DEBOUNCE_MS = 250;
|
||||
var PER_APP_LIMIT = 8;
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function cleanUrl(u) {
|
||||
if (!u) return null;
|
||||
u = String(u).trim();
|
||||
if (!u || u.indexOf("{{") !== -1) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
function withSlash(u) {
|
||||
return u.charAt(u.length - 1) === "/" ? u : u + "/";
|
||||
}
|
||||
|
||||
/* Même règle que la barre v0.1.6 : première étiquette en -dev ET
|
||||
* domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */
|
||||
function isDevHost(hostname) {
|
||||
if (!hostname) return false;
|
||||
var h = String(hostname).toLowerCase();
|
||||
if (h.slice(-13) !== ".dracodev.net") return false;
|
||||
var first = h.split(".")[0];
|
||||
return first.length > 4 && first.slice(-4) === "-dev";
|
||||
}
|
||||
|
||||
/* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */
|
||||
function resolveBases(opts) {
|
||||
opts = opts || {};
|
||||
var dev = isDevHost(opts.host);
|
||||
var out = {};
|
||||
APPS.forEach(function (app) {
|
||||
var key = app.key;
|
||||
var u = cleanUrl(opts.explicit && opts.explicit[key]);
|
||||
if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]);
|
||||
if (!u && dev) u = DEV[key];
|
||||
if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]);
|
||||
if (!u) u = PROD[key];
|
||||
out[key] = withSlash(u);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function searchPath(app, q) {
|
||||
if (app === "obsigate") {
|
||||
return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT;
|
||||
}
|
||||
return "api/search?q=" + encodeURIComponent(q);
|
||||
}
|
||||
|
||||
function snippetOf(s, n) {
|
||||
s = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
|
||||
return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s;
|
||||
}
|
||||
|
||||
/* Normalise la réponse d'une app en [{ title, snippet, context, url }].
|
||||
* base : base résolue de l'app (avec / final). q : requête (liens TikTak). */
|
||||
function normalize(app, data, base, q) {
|
||||
data = data || {};
|
||||
if (app === "obsigate") {
|
||||
return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) {
|
||||
return {
|
||||
title: r.title || r.path || "Sans titre",
|
||||
snippet: snippetOf(r.snippet || ""),
|
||||
context: (r.vault || "") + (r.path ? " · " + r.path : ""),
|
||||
url:
|
||||
base +
|
||||
"#file=" +
|
||||
encodeURIComponent(r.vault || "") +
|
||||
":" +
|
||||
encodeURIComponent(r.path || ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
if (app === "flowdeck") {
|
||||
var items = (data.pages || []).concat(data.collections || []);
|
||||
return items.slice(0, PER_APP_LIMIT).map(function (r) {
|
||||
var rel = String(r.url || "").replace(/^\//, "");
|
||||
return {
|
||||
title: r.title || "Sans titre",
|
||||
snippet: snippetOf(r.excerpt || ""),
|
||||
context: r.subtitle || (r.type === "collection" ? "Base" : "Page"),
|
||||
url: base + rel,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (app === "tiktak") {
|
||||
return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) {
|
||||
var ctx = t.status === "done" ? "Terminée" : "Active";
|
||||
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
|
||||
return {
|
||||
title: t.title || "Sans titre",
|
||||
snippet: snippetOf(t.description || ""),
|
||||
context: "Tâche · " + ctx,
|
||||
url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (app === "kanbante") {
|
||||
var rows = [];
|
||||
(data.groups || []).forEach(function (g) {
|
||||
(g.rows || []).forEach(function (row) {
|
||||
rows.push({ group: g.full_name, row: row });
|
||||
});
|
||||
});
|
||||
return rows.slice(0, PER_APP_LIMIT).map(function (x) {
|
||||
var issue = x.row.issue || {};
|
||||
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
|
||||
return {
|
||||
title: "#" + issue.number + " " + (issue.title || "Sans titre"),
|
||||
snippet: snippetOf(issue.body || ""),
|
||||
context: x.group + (col ? " · " + col : ""),
|
||||
url: base + "board/" + x.group,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/* Recherche native de l'app (repli quand la section est en erreur). */
|
||||
function nativeSearchUrl(app, base, q) {
|
||||
if (app === "tiktak" || app === "kanbante") {
|
||||
return base + "search?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") {
|
||||
return "Ouvrir la recherche dans " + appLabel(app);
|
||||
}
|
||||
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
|
||||
}
|
||||
|
||||
function appLabel(key) {
|
||||
for (var i = 0; i < APPS.length; i++) {
|
||||
if (APPS[i].key === key) return APPS[i].label;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
var api = {
|
||||
esc: esc,
|
||||
isDevHost: isDevHost,
|
||||
resolveBases: resolveBases,
|
||||
searchPath: searchPath,
|
||||
normalize: normalize,
|
||||
nativeSearchUrl: nativeSearchUrl,
|
||||
PROD: PROD,
|
||||
DEV: DEV,
|
||||
};
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
global.RecherchePartout = api;
|
||||
|
||||
/* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
function fetchSection(app, url, onDone) {
|
||||
var ctrl = null;
|
||||
var timer = null;
|
||||
try {
|
||||
ctrl = new AbortController();
|
||||
timer = setTimeout(function () {
|
||||
ctrl.abort();
|
||||
}, TIMEOUT_MS);
|
||||
} catch (e) {
|
||||
ctrl = null;
|
||||
}
|
||||
var opts = { headers: { Accept: "application/json" }, credentials: "include" };
|
||||
if (ctrl) opts.signal = ctrl.signal;
|
||||
fetch(url, opts)
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
var err = new Error("HTTP " + resp.status);
|
||||
err.status = resp.status;
|
||||
throw err;
|
||||
}
|
||||
return resp.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (timer) clearTimeout(timer);
|
||||
onDone(null, data);
|
||||
})
|
||||
.catch(function (err) {
|
||||
if (timer) clearTimeout(timer);
|
||||
onDone(err, null);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSection(section, state) {
|
||||
/* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */
|
||||
var body = section.querySelector(".rp-body");
|
||||
var count = section.querySelector(".rp-count");
|
||||
var toutVoir = section.querySelector(".rp-all");
|
||||
toutVoir.href = nativeSearchUrl(state.app, state.base, state.q);
|
||||
toutVoir.textContent = "Tout voir";
|
||||
toutVoir.title = nativeSearchLabel(state.app);
|
||||
if (state.status === "loading") {
|
||||
count.textContent = "";
|
||||
body.innerHTML = '<p class="rp-state">Recherche…</p>';
|
||||
return;
|
||||
}
|
||||
if (state.status === "error") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state rp-error">' +
|
||||
esc(state.message) +
|
||||
' — <a href="' +
|
||||
esc(nativeSearchUrl(state.app, state.base, state.q)) +
|
||||
'">' +
|
||||
esc(nativeSearchLabel(state.app)) +
|
||||
"</a></p>";
|
||||
return;
|
||||
}
|
||||
if (state.status === "empty") {
|
||||
count.textContent = "";
|
||||
body.innerHTML =
|
||||
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
|
||||
return;
|
||||
}
|
||||
count.textContent = String(state.items.length);
|
||||
body.innerHTML = state.items
|
||||
.map(function (item) {
|
||||
return (
|
||||
'<a class="rp-item" data-rp-link href="' +
|
||||
esc(item.url) +
|
||||
'">' +
|
||||
'<span class="rp-title">' +
|
||||
esc(item.title) +
|
||||
"</span>" +
|
||||
(item.context
|
||||
? '<span class="rp-context">' + esc(item.context) + "</span>"
|
||||
: "") +
|
||||
(item.snippet
|
||||
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
|
||||
: "") +
|
||||
"</a>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function init() {
|
||||
var root = document.getElementById("recherche");
|
||||
if (!root) return;
|
||||
var input = document.getElementById("rp-q");
|
||||
if (!input) return;
|
||||
var hostApp = root.getAttribute("data-host") || "";
|
||||
var explicit = {};
|
||||
APPS.forEach(function (app) {
|
||||
var v = root.getAttribute("data-base-" + app.key);
|
||||
if (v) explicit[app.key] = v;
|
||||
});
|
||||
var barUrls = {};
|
||||
try {
|
||||
if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls;
|
||||
} catch (e) {}
|
||||
var tabHrefs = {};
|
||||
try {
|
||||
document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) {
|
||||
var href = tab.getAttribute("href");
|
||||
if (href) tabHrefs[tab.getAttribute("data-app")] = href;
|
||||
});
|
||||
} catch (e) {}
|
||||
var bases = resolveBases({
|
||||
host: global.location.hostname,
|
||||
explicit: explicit,
|
||||
barUrls: barUrls,
|
||||
tabHrefs: tabHrefs,
|
||||
});
|
||||
var sections = {};
|
||||
APPS.forEach(function (app) {
|
||||
var el = document.querySelector('[data-rp-section="' + app.key + '"]');
|
||||
if (el) sections[app.key] = el;
|
||||
});
|
||||
|
||||
var currentQ = "";
|
||||
var runId = 0;
|
||||
var debounceTimer = null;
|
||||
|
||||
function runSearch(q) {
|
||||
currentQ = q;
|
||||
var myRun = ++runId;
|
||||
if (!q) {
|
||||
APPS.forEach(function (app) {
|
||||
var sec = sections[app.key];
|
||||
if (!sec) return;
|
||||
sec.querySelector(".rp-body").innerHTML =
|
||||
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
|
||||
sec.querySelector(".rp-count").textContent = "";
|
||||
sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], "");
|
||||
});
|
||||
return;
|
||||
}
|
||||
APPS.forEach(function (app) {
|
||||
var sec = sections[app.key];
|
||||
if (!sec) return;
|
||||
renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q });
|
||||
var url;
|
||||
if (app.key === hostApp) {
|
||||
/* Même origine : pas de CORS, pas de CSP cross-origin. */
|
||||
url = "/" + searchPath(app.key, q);
|
||||
} else {
|
||||
url = bases[app.key] + searchPath(app.key, q);
|
||||
}
|
||||
fetchSection(app.key, url, function (err, data) {
|
||||
if (myRun !== runId) return;
|
||||
if (err) {
|
||||
var msg;
|
||||
if (err.name === "AbortError") msg = "Délai dépassé (8 s)";
|
||||
else if (err.status === 401 || err.status === 403)
|
||||
msg = "Connexion requise sur " + app.label;
|
||||
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
|
||||
else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
|
||||
renderSection(sec, {
|
||||
status: "error",
|
||||
message: msg,
|
||||
app: app.key,
|
||||
base: bases[app.key],
|
||||
q: q,
|
||||
});
|
||||
return;
|
||||
}
|
||||
var items = normalize(app.key, data, bases[app.key], q);
|
||||
renderSection(sec, {
|
||||
status: items.length ? "ok" : "empty",
|
||||
items: items,
|
||||
app: app.key,
|
||||
base: bases[app.key],
|
||||
q: q,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleSearch(q, immediate) {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (immediate) {
|
||||
runSearch(q);
|
||||
return;
|
||||
}
|
||||
debounceTimer = setTimeout(function () {
|
||||
runSearch(q);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function syncUrl(q) {
|
||||
try {
|
||||
var url = new URL(global.location.href);
|
||||
if (q) url.searchParams.set("q", q);
|
||||
else url.searchParams.delete("q");
|
||||
global.history.replaceState(null, "", url.toString());
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
var q = input.value.trim();
|
||||
syncUrl(q);
|
||||
scheduleSearch(q, false);
|
||||
});
|
||||
input.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") {
|
||||
var first = document.querySelector("[data-rp-link]");
|
||||
if (first) {
|
||||
e.preventDefault();
|
||||
first.click();
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
input.value = "";
|
||||
syncUrl("");
|
||||
scheduleSearch("", true);
|
||||
input.focus();
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
||||
}
|
||||
});
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
function (e) {
|
||||
var target = e.target;
|
||||
var typing =
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable);
|
||||
if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
input.focus();
|
||||
input.select();
|
||||
return;
|
||||
}
|
||||
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
||||
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
function moveFocus(delta, e) {
|
||||
var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]"));
|
||||
if (!links.length) return;
|
||||
var active = document.activeElement;
|
||||
var idx = links.indexOf(active);
|
||||
var next;
|
||||
if (idx === -1) next = delta > 0 ? 0 : links.length - 1;
|
||||
else next = (idx + delta + links.length) % links.length;
|
||||
e.preventDefault();
|
||||
links[next].focus();
|
||||
}
|
||||
|
||||
var params = new URLSearchParams(global.location.search);
|
||||
var initialQ = (params.get("q") || "").trim();
|
||||
if (initialQ) {
|
||||
input.value = initialQ;
|
||||
runSearch(initialQ);
|
||||
} else {
|
||||
runSearch("");
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})(
|
||||
typeof window !== "undefined"
|
||||
? window
|
||||
: typeof globalThis !== "undefined"
|
||||
? globalThis
|
||||
: this
|
||||
);
|
||||
@@ -4,6 +4,7 @@ import { api, AuthManager } from './auth.js';
|
||||
import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
|
||||
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
|
||||
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
|
||||
import { openCoffresView, openCoffresNewSecret, openCoffresAcces, openCoffresJournal, getCoffresForSidebar, invalidateCoffresCache } from './coffres.js';
|
||||
import { performAdvancedSearch } from './search.js';
|
||||
import { t } from './i18n.js';
|
||||
import { filterChatMessages as _filterChat } from './filechat.js';
|
||||
@@ -46,6 +47,168 @@ function _sharedIcon(size = 16) {
|
||||
return i;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coffre Secrets — nœud virtuel « Secrets » sous le Home (Phase 1c, #19).
|
||||
// Patron exact du dossier virtuel « Partage » (#196) : inséré à la racine
|
||||
// du Home de l'utilisateur courant seulement, sans dossier sur disque, sans
|
||||
// passer par l'indexeur. Contrairement à « Partage », le nœud est permanent
|
||||
// (même sans coffre) : cliquer dessus ouvre la vue Secrets, qui crée le
|
||||
// coffre personnel à la première ouverture si besoin.
|
||||
// AUCUNE action de fichier n'est attachée à ce nœud ni à ses enfants :
|
||||
// ni attachTreeItemActionButton, ni ContextMenuManager (qui proposerait
|
||||
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Icône coffre-fort du nœud Secrets (SVG Safe Vault Streamline, fourni par Bruno), distincte de la maison et du diamant. Le tracé utilise currentColor pour rester lisible en thème clair comme sombre (le noir d'origine serait invisible en sombre). */
|
||||
function _secretsIcon() {
|
||||
const s = document.createElement("span");
|
||||
s.className = "coffres-icon-lock";
|
||||
s.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" aria-hidden="true"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M12.5 0.5h-11c-0.552285 0 -1 0.447715 -1 1V11c0 0.5523 0.447715 1 1 1h11c0.5523 0 1 -0.4477 1 -1V1.5c0 -0.552285 -0.4477 -1 -1 -1Z"/><path d="M8.5 8c0.9665 0 1.75 -0.7835 1.75 -1.75S9.4665 4.5 8.5 4.5s-1.75 0.7835 -1.75 1.75S7.5335 8 8.5 8Z"/><path d="M8.5 3.25V4.5"/><path d="M8.5 8v1.25"/><path d="M11.5 6.25h-1.25"/><path d="M6.75 6.25H5.5"/><path d="m10.62 4.12988 -0.88001 0.88"/><path d="m7.26 7.48999 -0.88 0.88"/><path d="m10.62 8.36999 -0.88001 -0.88"/><path d="m7.26 5.00988 -0.88 -0.88"/><path d="M3 4.5V8"/><path d="M2 12v1.5"/><path d="M11.5 12v1.5"/></g></svg>';
|
||||
return s;
|
||||
}
|
||||
|
||||
function _isOwnHome(vaultName) {
|
||||
const me = (AuthManager.getUser() || {}).username;
|
||||
return !!me && vaultName === `home-${me}`;
|
||||
}
|
||||
|
||||
/** Construit le nœud Secrets + son conteneur d'enfants (coffres). */
|
||||
async function _buildSecretsNode(vaultName) {
|
||||
const pair = document.createDocumentFragment();
|
||||
const item = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Secrets", "data-type": "virtual-secrets" }, [icon("chevron-right", 14), _secretsIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("coffres.secrets"))])]);
|
||||
const sub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Secrets` });
|
||||
item.addEventListener("click", async () => {
|
||||
scrollTreeItemIntoView(item, false);
|
||||
if (sub.classList.contains("collapsed")) {
|
||||
if (sub.children.length === 0) await _fillSecretsNode(item, sub, vaultName);
|
||||
sub.classList.remove("collapsed");
|
||||
const chev = item.querySelector("[data-lucide]");
|
||||
if (chev) chev.setAttribute("data-lucide", "chevron-down");
|
||||
safeCreateIcons();
|
||||
} else {
|
||||
sub.classList.add("collapsed");
|
||||
const chev = item.querySelector("[data-lucide]");
|
||||
if (chev) chev.setAttribute("data-lucide", "chevron-right");
|
||||
safeCreateIcons();
|
||||
}
|
||||
await openCoffresView();
|
||||
closeMobileSidebar();
|
||||
});
|
||||
item.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
_openSecretsMenu(e.clientX, e.clientY);
|
||||
});
|
||||
const actionBtn = el("button", { class: "tree-item-action-btn", type: "button", title: t("coffres.secrets") }, [document.createTextNode("\u22EE")]);
|
||||
actionBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const r = actionBtn.getBoundingClientRect();
|
||||
_openSecretsMenu(Math.min(r.right, window.innerWidth - 16), r.bottom);
|
||||
});
|
||||
item.appendChild(actionBtn);
|
||||
pair.appendChild(item);
|
||||
pair.appendChild(sub);
|
||||
// Compteur et enfants, en arrière-plan : le nœud existe même si l'API échoue.
|
||||
_fillSecretsNode(item, sub, vaultName).catch(() => {});
|
||||
return pair;
|
||||
}
|
||||
|
||||
/** Remplit le compteur et les enfants (coffres) d'un nœud Secrets existant. */
|
||||
async function _fillSecretsNode(item, sub, vaultName) {
|
||||
const data = await getCoffresForSidebar();
|
||||
item.querySelectorAll(".badge-small, .coffres-alert-dot").forEach((n) => n.remove());
|
||||
if (!data.disabled && data.personnel) {
|
||||
if (!data.personnel.kek_configuree) {
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "coffres-alert-dot";
|
||||
dot.textContent = "\u26A0";
|
||||
dot.title = t("coffres.kek_absente");
|
||||
item.appendChild(dot);
|
||||
} else {
|
||||
item.appendChild(smallBadge(data.personnel.nb_secrets));
|
||||
}
|
||||
}
|
||||
sub.innerHTML = "";
|
||||
const coffres = (data.coffres || []).slice().sort((a, b) => {
|
||||
const aPers = a.proprietaire === (AuthManager.getUser() || {}).username && a.perimetre === "personnel" ? 0 : 1;
|
||||
const bPers = b.proprietaire === (AuthManager.getUser() || {}).username && b.perimetre === "personnel" ? 0 : 1;
|
||||
return aPers - bPers || String(a.nom).localeCompare(String(b.nom));
|
||||
});
|
||||
coffres.forEach((c) => {
|
||||
const me = (AuthManager.getUser() || {}).username;
|
||||
const label = c.proprietaire === me ? c.nom : `${c.nom} (${c.proprietaire})`;
|
||||
const child = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": `Secrets/${c.slug}`, "data-type": "virtual-coffre", "data-coffre-slug": c.slug }, [icon("lock", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(label)]), smallBadge(c.nb_secrets)]);
|
||||
child.addEventListener("click", async () => {
|
||||
scrollTreeItemIntoView(child, false);
|
||||
await openCoffresView(c.slug);
|
||||
closeMobileSidebar();
|
||||
});
|
||||
child.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
_openSecretsMenu(e.clientX, e.clientY);
|
||||
});
|
||||
sub.appendChild(child);
|
||||
});
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
/** Rafraîchit tous les nœuds Secrets affichés (après création, rotation, révocation…). */
|
||||
async function _refreshSecretsNodes() {
|
||||
const items = Array.from(document.querySelectorAll('[data-type="virtual-secrets"]'));
|
||||
for (const item of items) {
|
||||
const vaultName = item.dataset.vault;
|
||||
const sub = item.nextElementSibling;
|
||||
if (sub && sub.classList.contains("tree-children")) {
|
||||
await _fillSecretsNode(item, sub, vaultName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.__obsigateSecretsListener) {
|
||||
window.__obsigateSecretsListener = true;
|
||||
document.addEventListener("obsigate:coffres-changed", () => {
|
||||
invalidateCoffresCache();
|
||||
_refreshSecretsNodes().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
/** Menu ⋮ du nœud Secrets : Ouvrir, Nouveau secret, Gérer les accès, Journal.
|
||||
* Menu propre, jamais ContextMenuManager (pas d'action de fichier). */
|
||||
function _openSecretsMenu(x, y) {
|
||||
document.querySelectorAll(".coffres-context-menu").forEach((n) => n.remove());
|
||||
const menu = el("div", { class: "coffres-context-menu" }, []);
|
||||
const entries = [
|
||||
[t("coffres.detail"), () => openCoffresView()],
|
||||
[t("coffres.new_secret"), () => openCoffresNewSecret()],
|
||||
[t("coffres.acces"), () => openCoffresAcces()],
|
||||
[t("coffres.journal"), () => openCoffresJournal()],
|
||||
];
|
||||
entries.forEach(([label, run]) => {
|
||||
const b = el("button", { class: "coffres-context-item", type: "button" }, [document.createTextNode(label)]);
|
||||
b.addEventListener("click", async () => {
|
||||
menu.remove();
|
||||
await run();
|
||||
closeMobileSidebar();
|
||||
});
|
||||
menu.appendChild(b);
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
const rect = menu.getBoundingClientRect();
|
||||
menu.style.left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)) + "px";
|
||||
menu.style.top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)) + "px";
|
||||
const closer = (e) => {
|
||||
if (!menu.contains(e.target)) {
|
||||
menu.remove();
|
||||
document.removeEventListener("click", closer, true);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
}
|
||||
};
|
||||
const onKey = (e) => { if (e.key === "Escape") { menu.remove(); document.removeEventListener("click", closer, true); document.removeEventListener("keydown", onKey); } };
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", closer, true);
|
||||
document.addEventListener("keydown", onKey);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
@@ -494,6 +657,12 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||||
}
|
||||
});
|
||||
|
||||
// Coffre Secrets (Phase 1c) : même nœud virtuel que dans loadDirectory.
|
||||
if (me && vaultName === `home-${me}` && !dirPath) {
|
||||
const pair = await _buildSecretsNode(vaultName);
|
||||
if (pair) fragment.insertBefore(pair, fragment.firstChild);
|
||||
}
|
||||
|
||||
// Replace container content in a single batch operation to avoid flash
|
||||
container.textContent = "";
|
||||
container.appendChild(fragment);
|
||||
@@ -838,6 +1007,14 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
}
|
||||
});
|
||||
|
||||
// Coffre Secrets (Phase 1c) : nœud virtuel permanent en tête, à la racine
|
||||
// du Home de l'utilisateur courant seulement, jamais sous un autre Home
|
||||
// ni sous un vault de notes, et jamais comme un dossier physique.
|
||||
if (me && vaultName === `home-${me}` && !dirPath) {
|
||||
const pair = await _buildSecretsNode(vaultName);
|
||||
if (pair) fragment.insertBefore(pair, fragment.firstChild);
|
||||
}
|
||||
|
||||
container.appendChild(fragment);
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
+68
-3
@@ -1,5 +1,5 @@
|
||||
/* ObsiGate — Theme System
|
||||
* 15 themes x 2 modes (dark/light)
|
||||
* 16 themes x 2 modes (dark/light)
|
||||
* Persisted in localStorage as 'obsigate-theme'
|
||||
* Usage: import { initThemes, applyTheme, getCurrentTheme, THEMES } from './themes.js'
|
||||
*/
|
||||
@@ -45,6 +45,71 @@ var THEMES = {
|
||||
}
|
||||
},
|
||||
|
||||
// ── Thème Spark ──────────────────────────────────────────────────────
|
||||
// Palette source : console Spark (agent.dracodev.net), app/console/console.css
|
||||
// (variables --p-*). Mapping Spark -> ObsiGate, identique en structure pour
|
||||
// dark et light ; seules les valeurs changent (sauf accent/ok/warn/err).
|
||||
// Fidèle (valeur Spark directe) :
|
||||
// fond -> --bg-primary, --surface2 ; surface -> --surface ;
|
||||
// chip -> --bg-secondary, --tag-bg ; chip-survol -> --bg-hover ;
|
||||
// bord -> --border ; chip-bord -> --border-md, --scrollbar, --resize-handle ;
|
||||
// texte -> --text-primary, --text ; doux -> --text-secondary, --text-2 ;
|
||||
// faint -> --text-muted, --text-3 ; champ -> --search-bg ;
|
||||
// code -> --code-bg ; accent -> --accent, --accent-card, --purple, --ai ;
|
||||
// lien -> --accent-text (variante lisible de l'accent sur fond teinté) ;
|
||||
// ok -> --accent-green, --success, --green ;
|
||||
// danger -> --danger ; danger-fond -> --danger-bg ;
|
||||
// pastilles ok (fond) -> --success-bg, --green-bg ;
|
||||
// pastilles ok (texte) -> --ok ; ambre -> --warn ; rouge -> --err.
|
||||
// Approximations documentées (ObsiGate a plus de variables que Spark,
|
||||
// ce ne sont PAS des valeurs Spark) :
|
||||
// --bg-sidebar <- fond (Spark n'a pas de token sidebar dédié) ;
|
||||
// --accent-bg <- agent (surface teintée de Spark, pas de teinte lavande) ;
|
||||
// --surface3 <- agent ; --purple-bg <- appro-fond (fond teinté le plus
|
||||
// proche, la palette Spark n'a pas de teinte lavande) ;
|
||||
// --overlay-bg <- convention ObsiGate (rgba noir 0.3 clair / 0.5 sombre,
|
||||
// aucun token voile dans la palette Spark).
|
||||
// Non reportés (aucune variable ObsiGate correspondante dans les 39) :
|
||||
// bulle utilisateur/envoi #2f6fed, titres approbation #9a6a1c / #e8bc7a.
|
||||
// Une seule entrée 'spark' : dark et light sont ses deux modes, jamais
|
||||
// deux thèmes séparés. Modes high-contrast/sepia générés comme pour les
|
||||
// autres thèmes (à partir de dark/light).
|
||||
'spark': {
|
||||
name: 'Spark', desc: 'Palette de la console Spark',
|
||||
modes: {
|
||||
dark: {
|
||||
'--bg-primary': '#17181a', '--bg-secondary': '#26272b', '--bg-sidebar': '#17181a',
|
||||
'--bg-hover': '#2e3036', '--border': '#33353a', '--text-primary': '#eceeeb',
|
||||
'--text-secondary': '#a6aba0', '--text-muted': '#83887e', '--accent': '#7c5cd6',
|
||||
'--accent-green': '#2ea36b', '--tag-bg': '#26272b', '--tag-text': '#7c5cd6',
|
||||
'--code-bg': '#2c2e33', '--search-bg': '#1f2023', '--scrollbar': '#3a3c41',
|
||||
'--resize-handle': '#3a3c41', '--overlay-bg': 'rgba(0,0,0,0.5)', '--danger': '#e8a08d',
|
||||
'--danger-bg': '#40241f', '--success': '#2ea36b', '--success-bg': '#173d29',
|
||||
'--accent-card': '#7c5cd6', '--accent-bg': '#26272b', '--accent-text': '#b39df5',
|
||||
'--green': '#2ea36b', '--green-bg': '#173d29', '--purple': '#7c5cd6',
|
||||
'--purple-bg': '#2a2118', '--surface': '#1f2023', '--surface2': '#17181a',
|
||||
'--surface3': '#26272b', '--border-md': '#3a3c41', '--text': '#eceeeb',
|
||||
'--text-2': '#a6aba0', '--text-3': '#83887e', '--ok': '#7fd6a8',
|
||||
'--warn': '#e8a13c', '--ai': '#7c5cd6', '--err': '#cf4b32'
|
||||
},
|
||||
light: {
|
||||
'--bg-primary': '#fbfbfa', '--bg-secondary': '#f4f5f1', '--bg-sidebar': '#fbfbfa',
|
||||
'--bg-hover': '#e9ebe4', '--border': '#e4e5e0', '--text-primary': '#242624',
|
||||
'--text-secondary': '#6c7268', '--text-muted': '#8a8f84', '--accent': '#7c5cd6',
|
||||
'--accent-green': '#2ea36b', '--tag-bg': '#f4f5f1', '--tag-text': '#7c5cd6',
|
||||
'--code-bg': '#e8eae3', '--search-bg': '#ffffff', '--scrollbar': '#d6d8d1',
|
||||
'--resize-handle': '#d6d8d1', '--overlay-bg': 'rgba(0,0,0,0.3)', '--danger': '#a03a28',
|
||||
'--danger-bg': '#f6e3de', '--success': '#2ea36b', '--success-bg': '#dff2e7',
|
||||
'--accent-card': '#7c5cd6', '--accent-bg': '#eef0ea', '--accent-text': '#5b3df0',
|
||||
'--green': '#2ea36b', '--green-bg': '#dff2e7', '--purple': '#7c5cd6',
|
||||
'--purple-bg': '#fdf6ec', '--surface': '#ffffff', '--surface2': '#fbfbfa',
|
||||
'--surface3': '#eef0ea', '--border-md': '#d6d8d1', '--text': '#242624',
|
||||
'--text-2': '#6c7268', '--text-3': '#8a8f84', '--ok': '#1d7a4c',
|
||||
'--warn': '#e8a13c', '--ai': '#7c5cd6', '--err': '#cf4b32'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
'github': {
|
||||
name: 'GitHub', desc: 'Contraste doux',
|
||||
modes: {
|
||||
@@ -523,7 +588,7 @@ var THEMES = {
|
||||
};
|
||||
|
||||
// ── Theme Engine ─────────────────────────────────────────────────────
|
||||
var _currentTheme = 'defaut-obsigate';
|
||||
var _currentTheme = 'spark';
|
||||
var _currentMode = 'dark';
|
||||
|
||||
var AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia'];
|
||||
@@ -588,7 +653,7 @@ function applyTheme(themeKey, mode) {
|
||||
root.style.setProperty(key, vars[key]);
|
||||
});
|
||||
// #180 — the spreadsheet grid follows the active theme: derive its tokens
|
||||
// from the generic surfaces so every theme (15 built-in × 4 modes, custom
|
||||
// from the generic surfaces so every theme (16 built-in × 4 modes, custom
|
||||
// imports) gets a matching grid instead of the hardcoded dark/light pair.
|
||||
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
|
||||
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
|
||||
|
||||
+103
-1
@@ -2666,5 +2666,107 @@
|
||||
"guide105.dl_md_title": "Download this guide as Markdown",
|
||||
"guide105.dl_pdf_title": "Download this guide as PDF",
|
||||
"guide105.export_title": "ObsiGate User Guide",
|
||||
"guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application."
|
||||
"guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application.",
|
||||
"coffres.acces": "Access and consumers",
|
||||
"coffres.actions": "Actions",
|
||||
"coffres.active": "Active",
|
||||
"coffres.confirm_dek_rotate": "Rotate this vault's DEK? Secrets will be re-encrypted, with no change for consumers.",
|
||||
"coffres.confirm_delete": "Delete this secret? The following grants will break:",
|
||||
"coffres.confirm_revoke_consumer": "Revoke this consumer? All of its distributions will be denied.",
|
||||
"coffres.confirm_revoke_octroi": "Revoke this grant? Access will be denied on the next call.",
|
||||
"coffres.consumer_no_distribution": "No distribution in the recent log.",
|
||||
"coffres.consumers": "Consumers",
|
||||
"coffres.copied": "Copied.",
|
||||
"coffres.copy_failed": "Copy failed.",
|
||||
"coffres.copy_generated": "Copy the generated value",
|
||||
"coffres.copy_reference": "Copy",
|
||||
"coffres.create_coffre_title": "New vault",
|
||||
"coffres.create_octroi": "Create a grant",
|
||||
"coffres.create_secret_title": "New secret",
|
||||
"coffres.created": "Created",
|
||||
"coffres.dek_rotate": "Rotate DEK",
|
||||
"coffres.delete": "Delete",
|
||||
"coffres.deleted": "Secret deleted.",
|
||||
"coffres.description": "Description",
|
||||
"coffres.detail": "Details",
|
||||
"coffres.disabled": "Secret vaults are disabled on this instance (OBSIGATE_SECRETS_ENABLED).",
|
||||
"coffres.expiration": "Expiration",
|
||||
"coffres.expired": "Expired",
|
||||
"coffres.expires": "Expires",
|
||||
"coffres.expiring_soon": "Expiring soon",
|
||||
"coffres.generate": "Generate",
|
||||
"coffres.generate_unavailable": "Generation is unavailable in this browser.",
|
||||
"coffres.grant_all_coffre": "Whole vault",
|
||||
"coffres.journal": "Access log",
|
||||
"coffres.kek_absente": "Master key missing: metadata is visible, but no secret can be created or distributed.",
|
||||
"coffres.kek_generate": "Generate a KEK",
|
||||
"coffres.kek_generate_hint": "The generated KEK is returned only once and is never stored by ObsiGate: back it up outside the repository immediately.",
|
||||
"coffres.kek_new": "New KEK",
|
||||
"coffres.kek_ok": "Master key configured.",
|
||||
"coffres.kek_once_hint": "Copy it now and back it up outside the repository: it will never be shown again.",
|
||||
"coffres.kek_rotate": "Rotate KEK",
|
||||
"coffres.kek_rotated": "KEK rotated. Remember to replace the KEK in the environment or KEK file.",
|
||||
"coffres.kek_rotation_hint": "After rotation, replace the KEK in the environment or KEK file, otherwise distributions will fail.",
|
||||
"coffres.last_use": "Last use",
|
||||
"coffres.loading": "Loading vaults…",
|
||||
"coffres.manual_copy": "One-time manual copy",
|
||||
"coffres.manual_copy_admin_only": "Admin only (no session-based distribution endpoint exists yet on the backend).",
|
||||
"coffres.manual_copy_confirm": "A temporary consumer will be created, the value will be copied to the clipboard without being shown, then the consumer will be revoked immediately. The distribution will be audited.",
|
||||
"coffres.modified": "Modified",
|
||||
"coffres.name": "Name",
|
||||
"coffres.name_hint": "Lowercase, digits, - or _ ; 3 to 128 characters (e.g. gitea-token).",
|
||||
"coffres.name_required": "A name is required.",
|
||||
"coffres.never_in_note": "Never paste the value into the note.",
|
||||
"coffres.new_coffre": "New vault",
|
||||
"coffres.new_consumer": "New consumer",
|
||||
"coffres.new_secret": "New secret",
|
||||
"coffres.no_coffre": "No visible vault.",
|
||||
"coffres.no_secrets": "No secret in this vault.",
|
||||
"coffres.note": "Note",
|
||||
"coffres.octroi_created": "Grant created.",
|
||||
"coffres.octroi_default_90": "Empty = default expiration (90 days).",
|
||||
"coffres.octroi_no_expiration": "No expiration",
|
||||
"coffres.octroi_revoked": "Grant revoked.",
|
||||
"coffres.octrois": "Grants",
|
||||
"coffres.perimetre": "Scope",
|
||||
"coffres.propose_octroi": "Propose a grant (optional)",
|
||||
"coffres.reference": "Reference",
|
||||
"coffres.revoke": "Revoke",
|
||||
"coffres.rotated": "Secret rotated: the new version is active.",
|
||||
"coffres.rotation": "Rotate",
|
||||
"coffres.rotation_title": "Rotate secret",
|
||||
"coffres.save": "Save",
|
||||
"coffres.saved": "Secret saved. The value will no longer be shown.",
|
||||
"coffres.search_placeholder": "Filter secrets…",
|
||||
"coffres.secret": "Secret",
|
||||
"coffres.secret_saved_octroi_failed": "Secret saved, but the grant failed",
|
||||
"coffres.secrets": "Secrets",
|
||||
"coffres.select_secret": "Select a secret to see its metadata.",
|
||||
"coffres.service": "Service",
|
||||
"coffres.show_before_save": "Show before saving",
|
||||
"coffres.slug": "Slug",
|
||||
"coffres.slug_hint": "Lowercase, digits, - or _ ; 3 to 64 characters.",
|
||||
"coffres.status": "Status",
|
||||
"coffres.subtitle": "Secret vaults — metadata only, a value is never shown again after it is saved.",
|
||||
"coffres.target_id": "Target id",
|
||||
"coffres.target_id_required": "A target id is required.",
|
||||
"coffres.target_type": "Target type",
|
||||
"coffres.title": "Secrets",
|
||||
"coffres.token_once": "Token (shown only once)",
|
||||
"coffres.token_once_hint": "Copy it now: it will never be shown again.",
|
||||
"coffres.type": "Type",
|
||||
"coffres.value": "Value",
|
||||
"coffres.value_required": "A value is required.",
|
||||
"coffres.version": "Version",
|
||||
"coffres.import_existing": "Import existing",
|
||||
"coffres.import_title": "Import existing keys",
|
||||
"coffres.import_has_value": "Value present",
|
||||
"coffres.import_no_value": "No value",
|
||||
"coffres.import_already_exists": "Already in the vault",
|
||||
"coffres.import_migrated": "Migrated",
|
||||
"coffres.import_warning": "The AI and tool keys from data/api_keys.json will be copied into the instance vault “ObsiGate interne”. Import deletes nothing. Clearing the plaintext is a separate step, to confirm explicitly — note: the current readers (AI, tools) still read api_keys.json, so clearing deprives them of the key until their reader is wired to the vault.",
|
||||
"coffres.import_done": "Import finished",
|
||||
"coffres.import_purge": "Delete plaintext",
|
||||
"coffres.import_purge_confirm": "Permanently delete the following migrated keys from plaintext? This cannot be undone without restoring the file:",
|
||||
"coffres.import_purged": "Plaintext deleted"
|
||||
}
|
||||
|
||||
+103
-1
@@ -2666,5 +2666,107 @@
|
||||
"guide105.dl_md_title": "Télécharger ce guide en Markdown",
|
||||
"guide105.dl_pdf_title": "Télécharger ce guide en PDF",
|
||||
"guide105.export_title": "Guide d'utilisation ObsiGate",
|
||||
"guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application."
|
||||
"guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application.",
|
||||
"coffres.acces": "Accès et consommateurs",
|
||||
"coffres.actions": "Actions",
|
||||
"coffres.active": "Actif",
|
||||
"coffres.confirm_dek_rotate": "Pivoter la DEK de ce coffre ? Les secrets seront re-chiffrés, sans changement pour les consommateurs.",
|
||||
"coffres.confirm_delete": "Supprimer ce secret ? Les octrois suivants seront cassés :",
|
||||
"coffres.confirm_revoke_consumer": "Révoquer ce consommateur ? Toutes ses distributions seront refusées.",
|
||||
"coffres.confirm_revoke_octroi": "Révoquer cet octroi ? L'accès sera refusé dès le prochain appel.",
|
||||
"coffres.consumer_no_distribution": "Aucune distribution dans le journal récent.",
|
||||
"coffres.consumers": "Consommateurs",
|
||||
"coffres.copied": "Copié.",
|
||||
"coffres.copy_failed": "Copie impossible.",
|
||||
"coffres.copy_generated": "Copier la valeur générée",
|
||||
"coffres.copy_reference": "Copier",
|
||||
"coffres.create_coffre_title": "Nouveau coffre",
|
||||
"coffres.create_octroi": "Créer un octroi",
|
||||
"coffres.create_secret_title": "Nouveau secret",
|
||||
"coffres.created": "Créé",
|
||||
"coffres.dek_rotate": "Rotation DEK",
|
||||
"coffres.delete": "Supprimer",
|
||||
"coffres.deleted": "Secret supprimé.",
|
||||
"coffres.description": "Description",
|
||||
"coffres.detail": "Détail",
|
||||
"coffres.disabled": "Les coffres de secrets sont désactivés sur cette instance (OBSIGATE_SECRETS_ENABLED).",
|
||||
"coffres.expiration": "Expiration",
|
||||
"coffres.expired": "Expiré",
|
||||
"coffres.expires": "Expire",
|
||||
"coffres.expiring_soon": "Expire bientôt",
|
||||
"coffres.generate": "Générer",
|
||||
"coffres.generate_unavailable": "Génération indisponible dans ce navigateur.",
|
||||
"coffres.grant_all_coffre": "Tout le coffre",
|
||||
"coffres.journal": "Journal d'accès",
|
||||
"coffres.kek_absente": "Clé maître absente : les métadonnées sont visibles, mais aucun secret ne peut être créé ou distribué.",
|
||||
"coffres.kek_generate": "Générer une KEK",
|
||||
"coffres.kek_generate_hint": "La KEK générée n'est rendue qu'une fois et n'est jamais stockée par ObsiGate : sauvegardez-la hors dépôt immédiatement.",
|
||||
"coffres.kek_new": "Nouvelle KEK",
|
||||
"coffres.kek_ok": "Clé maître configurée.",
|
||||
"coffres.kek_once_hint": "Copiez-la maintenant et sauvegardez-la hors dépôt : elle ne sera plus jamais affichée.",
|
||||
"coffres.kek_rotate": "Rotation KEK",
|
||||
"coffres.kek_rotated": "KEK pivotée. Pensez à remplacer la KEK dans l'environnement ou le fichier KEK.",
|
||||
"coffres.kek_rotation_hint": "Après la rotation, remplacez la KEK dans l'environnement ou le fichier KEK, sinon les distributions échoueront.",
|
||||
"coffres.last_use": "Dernière utilisation",
|
||||
"coffres.loading": "Chargement des coffres…",
|
||||
"coffres.manual_copy": "Copie manuelle à usage unique",
|
||||
"coffres.manual_copy_admin_only": "Réservée aux administrateurs (aucun endpoint de distribution par session n'existe encore côté backend).",
|
||||
"coffres.manual_copy_confirm": "Un consommateur temporaire sera créé, la valeur sera copiée au presse-papiers sans être affichée, puis le consommateur sera révoqué immédiatement. La distribution sera auditée.",
|
||||
"coffres.modified": "Modifié",
|
||||
"coffres.name": "Nom",
|
||||
"coffres.name_hint": "Minuscules, chiffres, - ou _ ; 3 à 128 caractères (ex. gitea-token).",
|
||||
"coffres.name_required": "Un nom est requis.",
|
||||
"coffres.never_in_note": "Ne collez jamais la valeur dans la note.",
|
||||
"coffres.new_coffre": "Nouveau coffre",
|
||||
"coffres.new_consumer": "Nouveau consommateur",
|
||||
"coffres.new_secret": "Nouveau secret",
|
||||
"coffres.no_coffre": "Aucun coffre visible.",
|
||||
"coffres.no_secrets": "Aucun secret dans ce coffre.",
|
||||
"coffres.note": "Note",
|
||||
"coffres.octroi_created": "Octroi créé.",
|
||||
"coffres.octroi_default_90": "Vide = expiration par défaut (90 jours).",
|
||||
"coffres.octroi_no_expiration": "Sans expiration",
|
||||
"coffres.octroi_revoked": "Octroi révoqué.",
|
||||
"coffres.octrois": "Octrois",
|
||||
"coffres.perimetre": "Périmètre",
|
||||
"coffres.propose_octroi": "Proposer un octroi (optionnel)",
|
||||
"coffres.reference": "Référence",
|
||||
"coffres.revoke": "Révoquer",
|
||||
"coffres.rotated": "Secret pivoté : la nouvelle version est active.",
|
||||
"coffres.rotation": "Rotation",
|
||||
"coffres.rotation_title": "Rotation du secret",
|
||||
"coffres.save": "Enregistrer",
|
||||
"coffres.saved": "Secret enregistré. La valeur ne sera plus affichée.",
|
||||
"coffres.search_placeholder": "Filtrer les secrets…",
|
||||
"coffres.secret": "Secret",
|
||||
"coffres.secret_saved_octroi_failed": "Secret enregistré, mais l'octroi a échoué",
|
||||
"coffres.secrets": "Secrets",
|
||||
"coffres.select_secret": "Sélectionnez un secret pour voir ses métadonnées.",
|
||||
"coffres.service": "Service",
|
||||
"coffres.show_before_save": "Afficher avant l'enregistrement",
|
||||
"coffres.slug": "Identifiant (slug)",
|
||||
"coffres.slug_hint": "Minuscules, chiffres, - ou _ ; 3 à 64 caractères.",
|
||||
"coffres.status": "Statut",
|
||||
"coffres.subtitle": "Coffres de secrets — métadonnées seules, aucune valeur n'est jamais affichée après l'enregistrement.",
|
||||
"coffres.target_id": "Identifiant de la cible",
|
||||
"coffres.target_id_required": "L'identifiant de la cible est requis.",
|
||||
"coffres.target_type": "Type de cible",
|
||||
"coffres.title": "Secrets",
|
||||
"coffres.token_once": "Jeton (affiché une seule fois)",
|
||||
"coffres.token_once_hint": "Copiez-le maintenant : il ne sera plus jamais affiché.",
|
||||
"coffres.type": "Type",
|
||||
"coffres.value": "Valeur",
|
||||
"coffres.value_required": "Une valeur est requise.",
|
||||
"coffres.version": "Version",
|
||||
"coffres.import_existing": "Importer l'existant",
|
||||
"coffres.import_title": "Importer les clés existantes",
|
||||
"coffres.import_has_value": "Valeur présente",
|
||||
"coffres.import_no_value": "Aucune valeur",
|
||||
"coffres.import_already_exists": "Déjà dans le coffre",
|
||||
"coffres.import_migrated": "Migré",
|
||||
"coffres.import_warning": "Les clés IA et clés d'outils de data/api_keys.json seront copiées dans le coffre d'instance « ObsiGate interne ». L'import ne supprime rien. La purge du clair est une étape séparée, à confirmer explicitement — attention : les lecteurs actuels (IA, outils) lisent encore api_keys.json, la purge les prive donc de la clé tant que leur lecteur n'est pas branché sur le coffre.",
|
||||
"coffres.import_done": "Import terminé",
|
||||
"coffres.import_purge": "Supprimer le clair",
|
||||
"coffres.import_purge_confirm": "Supprimer définitivement du clair les clés migrées suivantes ? Cette action est irréversible sans restauration du fichier :",
|
||||
"coffres.import_purged": "Clair supprimé"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter
|
||||
* toute collision avec les styles de l'hôte. Couleurs neutres héritées
|
||||
* autant que possible : la page reste lisible en thème clair et sombre. */
|
||||
.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
|
||||
.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; }
|
||||
.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; }
|
||||
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
|
||||
.rp-input {
|
||||
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
|
||||
border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit;
|
||||
}
|
||||
.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
|
||||
.rp-section { margin-bottom: 28px; }
|
||||
.rp-head {
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px;
|
||||
}
|
||||
.rp-head h2 { font-size: 1.05rem; margin: 0; }
|
||||
.rp-count {
|
||||
font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff;
|
||||
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
|
||||
}
|
||||
.rp-count:empty { display: none; }
|
||||
.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; }
|
||||
.rp-state { opacity: .75; margin: 8px 0; }
|
||||
.rp-error { opacity: 1; }
|
||||
.rp-item {
|
||||
display: block; padding: 8px 10px; border-radius: 8px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
|
||||
.rp-item:focus-visible { outline: 2px solid #3b82f6; }
|
||||
.rp-title { display: block; font-weight: 600; }
|
||||
.rp-context { display: block; font-size: .8rem; opacity: .65; }
|
||||
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
|
||||
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rechercher partout — ObsiGate</title>
|
||||
<link rel="stylesheet" href="/static/recherche.css">
|
||||
<style>
|
||||
/* Navigation minimale (la barre complète vit dans index.html, la SPA). */
|
||||
.rp-nav { display: flex; gap: 16px; padding: 12px 16px; font-size: .9rem; }
|
||||
.rp-nav a { color: inherit; opacity: .8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Jetons {{URL_*}} remplacés par la route /recherche (même contrat
|
||||
que le fragment de barre). Servie brute via /static, la page
|
||||
retomberait sur les défauts prod (les jetons sont ignorés par
|
||||
le JS) — jamais d'URL inventée. -->
|
||||
<nav class="rp-nav">
|
||||
<a href="{{URL_OBSIGATE}}">ObsiGate</a>
|
||||
<a href="{{URL_FLOWDECK}}">Flowdeck</a>
|
||||
<a href="{{URL_TIKTAK}}">TikTak</a>
|
||||
<a href="{{URL_KANBANTE}}">Kanbante</a>
|
||||
</nav>
|
||||
<main class="rp-page" id="recherche" data-host="obsigate"
|
||||
data-base-obsigate="{{URL_OBSIGATE}}"
|
||||
data-base-flowdeck="{{URL_FLOWDECK}}"
|
||||
data-base-tiktak="{{URL_TIKTAK}}"
|
||||
data-base-kanbante="{{URL_KANBANTE}}">
|
||||
<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.</p>
|
||||
<div class="rp-form">
|
||||
<input id="rp-q" class="rp-input" type="search" name="q" value=""
|
||||
placeholder="Rechercher partout…" autocomplete="off" autofocus>
|
||||
</div>
|
||||
<section class="rp-section" data-rp-section="obsigate">
|
||||
<div class="rp-head"><h2>ObsiGate — notes</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="flowdeck">
|
||||
<div class="rp-head"><h2>Flowdeck — pages</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="tiktak">
|
||||
<div class="rp-head"><h2>TikTak — tâches</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="kanbante">
|
||||
<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>
|
||||
</main>
|
||||
<script src="/static/js/recherche.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+92
-45
@@ -11,58 +11,58 @@
|
||||
|
||||
/* ── Editor save dot state colors ── */
|
||||
:root {
|
||||
--ok: #34d399;
|
||||
--warn: #fbbf24;
|
||||
--ai: #8b5cf6;
|
||||
--err: #f87171;
|
||||
--ok: #7fd6a8;
|
||||
--warn: #e8a13c;
|
||||
--ai: #7c5cd6;
|
||||
--err: #cf4b32;
|
||||
}
|
||||
|
||||
/* ===== THEME — DARK (fallback default, no data-theme) ===== */
|
||||
:root {
|
||||
--bg-primary: #0f1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-sidebar: #13161d;
|
||||
--bg-hover: #1f2430;
|
||||
--border: #21262d;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #484f58;
|
||||
--accent: #58a6ff;
|
||||
--accent-green: #3fb950;
|
||||
--tag-bg: #1f2a3a;
|
||||
--tag-text: #79c0ff;
|
||||
--code-bg: #161b22;
|
||||
--search-bg: #21262d;
|
||||
--scrollbar: #30363d;
|
||||
--resize-handle: #30363d;
|
||||
--bg-primary: #17181a;
|
||||
--bg-secondary: #26272b;
|
||||
--bg-sidebar: #17181a;
|
||||
--bg-hover: #2e3036;
|
||||
--border: #33353a;
|
||||
--text-primary: #eceeeb;
|
||||
--text-secondary: #a6aba0;
|
||||
--text-muted: #83887e;
|
||||
--accent: #7c5cd6;
|
||||
--accent-green: #2ea36b;
|
||||
--tag-bg: #26272b;
|
||||
--tag-text: #7c5cd6;
|
||||
--code-bg: #2c2e33;
|
||||
--search-bg: #1f2023;
|
||||
--scrollbar: #3a3c41;
|
||||
--resize-handle: #3a3c41;
|
||||
--overlay-bg: rgba(0, 0, 0, 0.5);
|
||||
--danger: #ff7b72;
|
||||
--danger-bg: #3d1a18;
|
||||
--success: #3fb950;
|
||||
--success-bg: #1a3d1f;
|
||||
--accent-card: #58a6ff;
|
||||
--accent-bg: #1f2a3a;
|
||||
--accent-text: #79c0ff;
|
||||
--green: #3fb950;
|
||||
--green-bg: #1a3d1f;
|
||||
--purple: #a371f7;
|
||||
--purple-bg: #2d1b4e;
|
||||
--surface: #161b22;
|
||||
--surface2: #0f1117;
|
||||
--surface3: #0d0f14;
|
||||
--border-md: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-2: #8b949e;
|
||||
--text-3: #484f58;
|
||||
--danger: #e8a08d;
|
||||
--danger-bg: #40241f;
|
||||
--success: #2ea36b;
|
||||
--success-bg: #173d29;
|
||||
--accent-card: #7c5cd6;
|
||||
--accent-bg: #26272b;
|
||||
--accent-text: #b39df5;
|
||||
--green: #2ea36b;
|
||||
--green-bg: #173d29;
|
||||
--purple: #7c5cd6;
|
||||
--purple-bg: #2a2118;
|
||||
--surface: #1f2023;
|
||||
--surface2: #17181a;
|
||||
--surface3: #26272b;
|
||||
--border-md: #3a3c41;
|
||||
--text: #eceeeb;
|
||||
--text-2: #a6aba0;
|
||||
--text-3: #83887e;
|
||||
--mono: "JetBrains Mono", monospace;
|
||||
--radius: 6px;
|
||||
--radius-lg: 10px;
|
||||
/* #154-A1 — data-grid tokens: the sheet reads slightly lighter than the
|
||||
chrome (toolbar/panels), headers are clearly distinct from cells. */
|
||||
--grid-bg: #161b22;
|
||||
--grid-header-bg: #1f2430;
|
||||
--grid-header-text: #e6edf3;
|
||||
--grid-border: #3d444d;
|
||||
--grid-bg: #1f2023;
|
||||
--grid-header-bg: #2e3036;
|
||||
--grid-header-text: #eceeeb;
|
||||
--grid-border: #33353a;
|
||||
--grid-zebra: rgba(255, 255, 255, 0.025);
|
||||
}
|
||||
|
||||
@@ -7700,6 +7700,8 @@ body.popup-mode .content-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 200ms ease;
|
||||
@@ -7715,8 +7717,16 @@ body.popup-mode .content-area {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
/* Correctif Phase 2 (modale de création de secret coupée en haut) :
|
||||
sans max-height ni scroll interne, un formulaire long dépassait le
|
||||
viewport. La modale est désormais bornée au viewport, en colonne
|
||||
flex, avec seul le corps qui défile ; l'en-tête et le pied restent
|
||||
toujours visibles. Sur petit écran, la largeur est bornée aussi. */
|
||||
min-width: min(400px, calc(100vw - 32px));
|
||||
max-width: min(600px, calc(100vw - 32px));
|
||||
max-height: calc(100dvh - 32px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: scale(0.9);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
@@ -7731,6 +7741,7 @@ body.popup-mode .content-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.obsigate-modal-title {
|
||||
@@ -7742,6 +7753,8 @@ body.popup-mode .content-area {
|
||||
|
||||
.obsigate-modal-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.obsigate-modal-footer {
|
||||
@@ -7750,6 +7763,7 @@ body.popup-mode .content-area {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
@@ -14296,7 +14310,7 @@ textarea.file-chat-input {
|
||||
/* — Barre d'apps commune dracodev + section « Liens » des notes
|
||||
(Fusion #215, snippet figé dracodev-commons v0.1.0) — */
|
||||
body.has-draco-bar .app-container { height: calc(100vh - 60px); }
|
||||
@media (max-width: 700px) {
|
||||
@media (max-width: 900px) {
|
||||
body.has-draco-bar .app-container { height: calc(100vh - 52px); }
|
||||
}
|
||||
.draco-liens {
|
||||
@@ -14331,3 +14345,36 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
|
||||
color: var(--text-primary); font: inherit; font-size: 13.5px;
|
||||
}
|
||||
.draco-liens-erreur { color: var(--err); font-size: 13px; margin: 8px 0 0; }
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Coffre Secrets (Phase 1c, issue #19) — vue Secrets + nœud sidebar.
|
||||
* Réutilise les variables de thème existantes ; aucune valeur de secret
|
||||
* n'est jamais affichée par ces styles (colonne valeur = •••••• en texte).
|
||||
* --------------------------------------------------------------------------- */
|
||||
.coffres-view { padding: 16px 20px; max-width: 1100px; }
|
||||
.coffres-subtitle { color: var(--text-muted); margin: 0 0 12px; }
|
||||
.coffres-banner { padding: 10px 12px; margin: 12px 0; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.coffres-banner.warn { border-color: var(--warning, #f59e0b); }
|
||||
.coffres-chips { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
|
||||
.coffres-chip { white-space: nowrap; }
|
||||
.coffres-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
|
||||
.coffres-actions .modal-input { flex: 1 1 220px; }
|
||||
.coffres-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||
.coffres-table th, .coffres-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border, #21262d); vertical-align: top; }
|
||||
.coffres-table tbody tr { cursor: pointer; }
|
||||
.coffres-table tbody tr:hover { background: var(--bg-hover, #1f2430); }
|
||||
.coffres-detail { margin-top: 16px; padding: 14px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; }
|
||||
.coffres-dl { display: grid; grid-template-columns: 160px 1fr; gap: 4px 12px; margin: 12px 0; }
|
||||
.coffres-dl dt { color: var(--text-muted); }
|
||||
.coffres-dl dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.coffres-line { padding: 6px 0; border-bottom: 1px solid var(--border, #21262d); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: space-between; }
|
||||
.coffres-empty { color: var(--text-muted); }
|
||||
.coffres-alerts { margin: 12px 0; }
|
||||
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
|
||||
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; }
|
||||
.coffres-icon-lock { display: inline-flex; align-items: center; line-height: 1; }
|
||||
.coffres-icon-lock svg { width: 16px; height: 16px; flex: none; }
|
||||
.coffres-alert-dot { color: var(--warning, #f59e0b); font-weight: 700; margin-left: 4px; }
|
||||
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
|
||||
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
|
||||
.coffres-context-item:hover { background: var(--bg-hover, #1f2430); }
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.71.0",
|
||||
"version": "2.81.4",
|
||||
"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": {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Alertes unifiées (Fusion #69, chantier 2 + chantier 5).
|
||||
*
|
||||
* Tests du module partagé frontend/draco-alertes.js (logique pure,
|
||||
* sans DOM) + vérification du gabarit livré frontend/index.html.
|
||||
* Copie vendangée byte-identique de Projets/dracodev-commons
|
||||
* ui/alertes/ (chantier 5) — seul le chargement change (ESM ici).
|
||||
* Le payload console n'est qu'un EXEMPLE DE FORMAT (voir README
|
||||
* commons) : aucun endpoint console n'est publié sur agent-commun
|
||||
* main au moment du chantier.
|
||||
*
|
||||
* Usage: node tests/frontend/draco-alertes.test.mjs
|
||||
*/
|
||||
import test from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const t = require(path.join(REPO_ROOT, "frontend", "draco-alertes.js"));
|
||||
|
||||
function sampleResults() {
|
||||
return {
|
||||
flowdeck: { items: t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
|
||||
notifications: [
|
||||
{ id: 1, ntype: "mention", title: "Mention budget", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||
{ id: 2, ntype: "reminder", title: "Rappel lu", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||
], unread: 1,
|
||||
}) },
|
||||
tiktak: { items: t.normalizeTiktak("https://tiktak.dracodev.net/", [
|
||||
{ id: 7, kind: "reminder", title: "Rappel tâche", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
|
||||
{ id: 8, kind: "share", title: "Partage lu", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
|
||||
]) },
|
||||
kanbante: { items: t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||
events: [
|
||||
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
|
||||
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
|
||||
],
|
||||
}) },
|
||||
obsigate: { items: [] },
|
||||
console: { items: [], sourceMissing: true },
|
||||
};
|
||||
}
|
||||
function allOf(results) {
|
||||
const all = [];
|
||||
for (const app of ["obsigate", "flowdeck", "tiktak", "kanbante", "console"]) {
|
||||
(results[app] && results[app].items || []).forEach((a) => all.push(a));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
test("ordre des onglets : Tout, apps, Console Spark, Historique", () => {
|
||||
assert.deepEqual(t.TABS, ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"]);
|
||||
assert.equal(t.TAB_LABELS.console, "Console Spark");
|
||||
});
|
||||
|
||||
test("resolveUrls: config explicite > hote -dev > href > defaut prod, agent inclus", () => {
|
||||
const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} });
|
||||
assert.equal(prod.tiktak, "https://tiktak.dracodev.net/");
|
||||
assert.equal(prod.agent, "https://agent.dracodev.net/");
|
||||
const dev = t.resolveUrls({ hostname: "tiktak-dev.dracodev.net", tabHrefs: {} });
|
||||
assert.equal(dev.flowdeck, "https://flowdeck-dev.dracodev.net/");
|
||||
assert.equal(dev.obsigate, "https://og-dev.dracodev.net/");
|
||||
assert.equal(dev.agent, "https://agent-dev.dracodev.net/");
|
||||
const explicit = t.resolveUrls({
|
||||
hostname: "tiktak-dev.dracodev.net",
|
||||
configUrls: { flowdeck: "https://flowdeck.dracodev.net/" },
|
||||
});
|
||||
assert.equal(explicit.flowdeck, "https://flowdeck.dracodev.net/");
|
||||
const href = t.resolveUrls({
|
||||
hostname: "example.org",
|
||||
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/", agent: "https://agent.dracodev.net/" },
|
||||
});
|
||||
assert.equal(href.kanbante, "https://kanbante-dev.dracodev.net/");
|
||||
assert.equal(href.agent, "https://agent.dracodev.net/");
|
||||
const token = t.resolveUrls({ hostname: "x", tabHrefs: { tiktak: "{{URL_TIKTAK}}" } });
|
||||
assert.equal(token.tiktak, "https://tiktak.dracodev.net/");
|
||||
assert.equal(t.isDevHost("tiktak-dev.dracodev.net.evil.com"), false);
|
||||
assert.equal(t.isDevHost("x-dev.dracodev.net"), true);
|
||||
});
|
||||
|
||||
test("normalizeFlowdeck: non lues actives, lien = base + url", () => {
|
||||
const items = t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
|
||||
notifications: [
|
||||
{ id: 1, ntype: "mention", title: "Mention", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
|
||||
{ id: 2, ntype: "reminder", title: "Rappel", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
|
||||
], unread: 1,
|
||||
});
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].active, true);
|
||||
assert.equal(items[0].state, "active");
|
||||
assert.equal(items[0].url, "https://flowdeck.dracodev.net/collections/3");
|
||||
assert.equal(items[1].active, false);
|
||||
assert.equal(items[1].url, "https://flowdeck.dracodev.net/");
|
||||
});
|
||||
|
||||
test("normalizeTiktak: lien tache, sinon page notifications", () => {
|
||||
const items = t.normalizeTiktak("https://tiktak.dracodev.net/", [
|
||||
{ id: 7, kind: "reminder", title: "Rappel", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
|
||||
{ id: 8, kind: "share", title: "Partage", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
|
||||
]);
|
||||
assert.equal(items[0].url, "https://tiktak.dracodev.net/?task=42");
|
||||
assert.equal(items[1].url, "https://tiktak.dracodev.net/notifications");
|
||||
assert.equal(items[0].active, true);
|
||||
});
|
||||
|
||||
test("normalizeKanbante: jamais actif (pas d'etat lu/non lu), lien board", () => {
|
||||
const items = t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||
events: [
|
||||
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
|
||||
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
|
||||
],
|
||||
});
|
||||
assert.equal(items.length, 2);
|
||||
assert.ok(items.every((i) => i.active === false));
|
||||
assert.equal(items[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps");
|
||||
assert.match(items[0].title, /#69/);
|
||||
});
|
||||
|
||||
test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
|
||||
const list = [
|
||||
{ app: "tiktak", active: false, date: "2026-10-28T12:00:00" },
|
||||
{ app: "flowdeck", active: true, date: "2026-10-26T12:00:00" },
|
||||
{ app: "flowdeck", active: true, date: "2026-10-28T12:00:00" },
|
||||
{ app: "kanbante", active: false, date: "" },
|
||||
];
|
||||
const sorted = t.sortAlerts(list);
|
||||
assert.equal(sorted[0].date, "2026-10-28T12:00:00");
|
||||
assert.equal(sorted[0].active, true);
|
||||
assert.equal(sorted[1].date, "2026-10-26T12:00:00");
|
||||
assert.equal(sorted[3].date, "");
|
||||
assert.equal(t.countActive(list), 2);
|
||||
});
|
||||
|
||||
test("scopeAlerts: Tout et onglets apps = actives seulement ; Historique = fermees seulement", () => {
|
||||
const r = sampleResults();
|
||||
const all = allOf(r);
|
||||
const tout = t.scopeAlerts(all, "tout", r);
|
||||
assert.equal(tout.items.length, 2);
|
||||
assert.ok(tout.items.every((a) => a.active));
|
||||
assert.equal(tout.items[0].date, "2026-10-28T10:00:00"); // date decroissante
|
||||
const fd = t.scopeAlerts(all, "flowdeck", r);
|
||||
assert.equal(fd.items.length, 1);
|
||||
assert.equal(fd.items[0].app, "flowdeck");
|
||||
const hist = t.scopeAlerts(all, "historique", r);
|
||||
assert.equal(hist.items.length, 4); // 1 FD lue + 1 TK lue + 2 KB
|
||||
assert.ok(hist.items.every((a) => !a.active));
|
||||
const og = t.scopeAlerts(all, "obsigate", r);
|
||||
assert.equal(og.items.length, 0);
|
||||
const cs = t.scopeAlerts(all, "console", r);
|
||||
assert.equal(cs.items.length, 0);
|
||||
assert.equal(cs.consoleMissing, true);
|
||||
});
|
||||
|
||||
test("scopeAlerts: une section en echec remonte aussi dans Historique (pas avalee)", () => {
|
||||
const r = sampleResults();
|
||||
r.flowdeck = { items: [], error: "Application injoignable depuis cet hôte (TypeError)." };
|
||||
const hist = t.scopeAlerts(allOf(r), "historique", r);
|
||||
assert.equal(hist.errors.length, 1);
|
||||
assert.equal(hist.errors[0].app, "flowdeck");
|
||||
const tout = t.scopeAlerts(allOf(r), "tout", r);
|
||||
assert.equal(tout.errors.length, 1);
|
||||
});
|
||||
|
||||
test("countByTab: compteurs par onglet sur l'echantillon", () => {
|
||||
const r = sampleResults();
|
||||
const c = t.countByTab(allOf(r), r);
|
||||
assert.deepEqual(c, {
|
||||
tout: 2, obsigate: 0, flowdeck: 1, tiktak: 1,
|
||||
kanbante: 0, console: 0, historique: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("filterAlerts: titre + contexte, insensible a la casse, sans melanger actifs/historique", () => {
|
||||
const r = sampleResults();
|
||||
const all = allOf(r);
|
||||
const tout = t.scopeAlerts(all, "tout", r).items;
|
||||
assert.equal(t.filterAlerts(tout, "budget").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "BUDGET").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "voir page").length, 1); // contexte (detail)
|
||||
assert.equal(t.filterAlerts(tout, "tache").length, 1);
|
||||
// « Rappel lu » est une alerte fermee : absente de Tout meme filtree.
|
||||
assert.equal(t.filterAlerts(tout, "Rappel lu").length, 0);
|
||||
const hist = t.scopeAlerts(all, "historique", r).items;
|
||||
assert.equal(t.filterAlerts(hist, "Rappel lu").length, 1);
|
||||
assert.equal(t.filterAlerts(tout, "").length, tout.length);
|
||||
});
|
||||
|
||||
test("presentation identique: meta Kanbante sans type brut anglais ; date illisible = sans date", () => {
|
||||
const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", {
|
||||
events: [{ id: 1, repo_full_name: "R", issue_number: 1, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" }],
|
||||
})[0];
|
||||
assert.ok(!t.metaLine(kb).includes("moved"));
|
||||
assert.match(t.metaLine(kb), /Kanbante · .* · Déplacée/);
|
||||
assert.equal(t.displayState({ active: true }), "Active");
|
||||
assert.equal(t.displayState({ active: false, app: "flowdeck" }), "Lue");
|
||||
const sansDate = { app: "tiktak", active: true, title: "x", detail: "", date: "nimporte-quoi", kind: "", url: "https://x/" };
|
||||
assert.match(t.metaLine(sansDate), /sans date/);
|
||||
});
|
||||
|
||||
test("hash URL: parse + round-trip, onglet inconnu rejete", () => {
|
||||
assert.equal(t.parseAlertTab("#alertes=historique"), "historique");
|
||||
assert.equal(t.parseAlertTab("#alertes=console"), "console");
|
||||
assert.equal(t.parseAlertTab("#alertes"), "tout");
|
||||
assert.equal(t.parseAlertTab("#autre"), null);
|
||||
assert.equal(t.parseAlertTab("#alertes=nimporte"), null);
|
||||
assert.equal(t.parseAlertTab(""), null);
|
||||
for (const tab of t.TABS) assert.equal(t.parseAlertTab(t.hashForTab(tab)), tab);
|
||||
assert.equal(t.hashForTab("nimporte"), "#alertes=tout");
|
||||
});
|
||||
|
||||
test("normalizeConsole (FORMAT EXEMPLE): statuts reels -> actif/ferme, lien console par defaut", () => {
|
||||
const items = t.normalizeConsole("https://agent.dracodev.net/", {
|
||||
alerts: [
|
||||
{ id: "m1", title: "Mission en pause budget", detail: "budget épuisé", status: "pause_budget", created_at: "2026-10-28T09:00:00" },
|
||||
{ id: "m2", title: "Accès requis", detail: "jeton manquant", status: "acces_requis", created_at: "2026-10-28T09:30:00" },
|
||||
{ id: "a1", title: "Approbation en attente", status: "en_attente", url: "/approbations", created_at: "2026-10-28T10:00:00" },
|
||||
{ id: "m3", title: "Mission terminée", status: "terminee", created_at: "2026-10-27T10:00:00" },
|
||||
{ id: "m4", title: "Sans statut", created_at: "2026-10-27T11:00:00" },
|
||||
],
|
||||
});
|
||||
assert.equal(items.length, 5);
|
||||
assert.equal(items[0].active, true);
|
||||
assert.equal(items[1].active, true);
|
||||
assert.equal(items[2].active, true);
|
||||
assert.equal(items[2].url, "https://agent.dracodev.net/approbations");
|
||||
assert.equal(items[3].active, false);
|
||||
assert.equal(items[4].active, true); // statut inconnu : expose en active, jamais masque
|
||||
assert.equal(items[0].url, "https://agent.dracodev.net/");
|
||||
const r = sampleResults();
|
||||
r.console = { items };
|
||||
const c = t.countByTab(allOf(r), r);
|
||||
assert.equal(c.console, 4);
|
||||
assert.equal(c.tout, 6);
|
||||
assert.equal(c.historique, 5);
|
||||
});
|
||||
|
||||
test("consoleSourceUrl: null sans configuration (aucune URL devineee)", () => {
|
||||
assert.equal(t.consoleSourceUrl({}), null);
|
||||
assert.equal(t.consoleSourceUrl({ consoleUrl: "https://agent-dev.dracodev.net/api/alertes" }),
|
||||
"https://agent-dev.dracodev.net/api/alertes");
|
||||
});
|
||||
|
||||
test("index.html: placeholder Alertes disparu, module charge", () => {
|
||||
const html = readFileSync(path.join(REPO_ROOT, "frontend", "index.html"), "utf8");
|
||||
assert.equal(html.includes("Alertes unifiées : arrive avec la fédération"), false);
|
||||
assert.equal(html.includes("/static/draco-alertes.js"), true);
|
||||
assert.equal(html.includes("/static/draco-alertes.css"), true);
|
||||
assert.equal(html.includes("DRACO_ALERTES.open()"), true);
|
||||
});
|
||||
|
||||
test("index.html chantier 5: onglet Console Spark en dernier, apres Kanbante", () => {
|
||||
const html = readFileSync(path.join(REPO_ROOT, "frontend", "index.html"), "utf8");
|
||||
const nav = /<nav class="draco-apptabs"[^>]*>([\s\S]*?)<\/nav>/.exec(html)[1];
|
||||
const apps = [...nav.matchAll(/data-app="([^"]+)"/g)].map((m) => m[1]);
|
||||
assert.deepEqual(apps, ["obsigate", "flowdeck", "tiktak", "kanbante", "agent"]);
|
||||
assert.ok(nav.includes('data-app="agent" href="https://agent.dracodev.net/"'));
|
||||
assert.ok(nav.includes("<strong>Console Spark</strong>"));
|
||||
// La réécriture DRACO_URLS couvre la clé "agent" (même boucle que les autres onglets).
|
||||
assert.ok(html.includes('window.DRACO_URLS[a.dataset.app]'));
|
||||
});
|
||||
|
||||
test("draco-alertes.js/.css chantier 5: onglets, filtre, etat console honnete", () => {
|
||||
const js = readFileSync(path.join(REPO_ROOT, "frontend", "draco-alertes.js"), "utf8");
|
||||
assert.ok(js.includes("draco-alertes-tab"));
|
||||
assert.ok(js.includes("historique"));
|
||||
assert.ok(js.includes("source indisponible"));
|
||||
assert.ok(js.includes("DRACO_ALERTES_CONSOLE_URL"));
|
||||
const css = readFileSync(path.join(REPO_ROOT, "frontend", "draco-alertes.css"), "utf8");
|
||||
assert.ok(css.includes(".draco-alertes-tab"));
|
||||
assert.ok(css.includes(".draco-alertes-filtre"));
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — tests de la logique pure de la page « Rechercher partout »
|
||||
* (fusion #68), sans DOM ni jsdom : résolution des bases, liens
|
||||
* profonds et normalisation des 4 formats d'API.
|
||||
*
|
||||
* Le fichier frontend/js/recherche.js est chargé en ESM (frontend/js
|
||||
* est "type": "module") ; ses helpers sont exposés via
|
||||
* globalThis.RecherchePartout. Usage : node tests/frontend/recherche.test.mjs
|
||||
*/
|
||||
import { strict as assert } from "node:assert";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const JS_FILE = path.resolve(__dirname, "..", "..", "frontend", "js", "recherche.js");
|
||||
|
||||
await import(pathToFileURL(JS_FILE).href);
|
||||
const rp = globalThis.RecherchePartout;
|
||||
assert.ok(rp, "RecherchePartout exposé");
|
||||
|
||||
let n = 0;
|
||||
const ok = (cond, label) => {
|
||||
assert.ok(cond, label);
|
||||
n += 1;
|
||||
};
|
||||
|
||||
ok(rp.isDevHost("og-dev.dracodev.net") === true, "dev reconnu");
|
||||
ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté");
|
||||
|
||||
const b = rp.resolveBases({
|
||||
host: "og-dev.dracodev.net",
|
||||
explicit: { tiktak: "https://tiktak-perso.example/" },
|
||||
barUrls: {},
|
||||
tabHrefs: {},
|
||||
});
|
||||
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(
|
||||
rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8",
|
||||
"path ObsiGate",
|
||||
);
|
||||
|
||||
const items = rp.normalize(
|
||||
"obsigate",
|
||||
{ results: [{ vault: "home-bruno", path: "Notes/Idée.md", title: "Idée", snippet: "extrait" }] },
|
||||
"https://og-dev.dracodev.net/",
|
||||
"idée",
|
||||
);
|
||||
ok(
|
||||
items[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md",
|
||||
"lien profond ObsiGate",
|
||||
);
|
||||
ok(
|
||||
rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") ===
|
||||
"https://tiktak-dev.dracodev.net/search?q=q",
|
||||
"repli TikTak",
|
||||
);
|
||||
|
||||
console.log(`recherche.test.mjs : ${n} assertions OK`);
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Émission d'événements vers le journal du service d'agents (#220,
|
||||
Fusion Lot 2 vague 5 — contrat ACC-0.2).
|
||||
|
||||
Un faux récepteur local capture les POST signés HMAC et les vérifie
|
||||
comme le fait le service d'agents ; on prouve aussi l'inactivité sans
|
||||
clé, la non-régression métier quand le récepteur est en panne, et le
|
||||
garde-fou central : le watcher / l'indexation n'émettent JAMAIS
|
||||
(seules les écritures explicites via l'API ou les outils le font).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
CLE = "cle-obsigate-de-test"
|
||||
|
||||
|
||||
class _Recepteur(BaseHTTPRequestHandler):
|
||||
recus = []
|
||||
|
||||
def do_POST(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
corps = self.rfile.read(n)
|
||||
type(self).recus.append(
|
||||
{"headers": {k.lower(): v for k, v in self.headers.items()},
|
||||
"corps": corps})
|
||||
self.send_response(201)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def _demarrer(monkeypatch):
|
||||
_Recepteur.recus = []
|
||||
serveur = HTTPServer(("127.0.0.1", 0), _Recepteur)
|
||||
port = serveur.server_address[1]
|
||||
threading.Thread(target=serveur.serve_forever, daemon=True).start()
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
|
||||
f"http://127.0.0.1:{port}/api/v1/evenements")
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||
return serveur
|
||||
|
||||
|
||||
def _attendre(types, delai=6.0):
|
||||
"""Attend que chaque type demandé soit arrivé ; retourne
|
||||
{type: enveloppe} — la signature HMAC de CHAQUE envoi est
|
||||
revérifiée ici, comme le fait le service."""
|
||||
vus = {}
|
||||
fin = time.time() + delai
|
||||
while time.time() < fin and not all(t in vus for t in types):
|
||||
for recu in list(_Recepteur.recus):
|
||||
env = json.loads(recu["corps"].decode("utf-8"))
|
||||
sig = recu["headers"].get("x-draco-signature", "")
|
||||
attendu = hmac.new(CLE.encode(), recu["corps"],
|
||||
hashlib.sha256).hexdigest()
|
||||
assert hmac.compare_digest(sig.lower(), attendu), \
|
||||
"signature HMAC invalide"
|
||||
vus.setdefault(env["type"], env)
|
||||
if not all(t in vus for t in types):
|
||||
time.sleep(0.05)
|
||||
manquants = [t for t in types if t not in vus]
|
||||
assert not manquants, f"événements jamais reçus : {manquants}"
|
||||
return vus
|
||||
|
||||
|
||||
# ── Enveloppe ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_enveloppe_et_signature(monkeypatch):
|
||||
from backend import agent_journal
|
||||
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||
env, corps = agent_journal.construire_enveloppe(
|
||||
agent_journal.TYPE_CREE, "TestVault", "Journal/nouvelle.md")
|
||||
assert env["source_app"] == "obsigate"
|
||||
assert env["domain"] == "pro" # repli : aucun domaine modélisé (Q-A)
|
||||
assert env["actor"] == "user"
|
||||
assert env["object"] == {
|
||||
"ref": "fichier:TestVault/Journal/nouvelle.md",
|
||||
"label": "nouvelle.md",
|
||||
"url": "/#file=TestVault:Journal/nouvelle.md",
|
||||
}
|
||||
assert json.loads(corps.decode("utf-8")) == env
|
||||
assert env["id"] and env["occurred_at"]
|
||||
sig = hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
|
||||
assert len(sig) == 64
|
||||
|
||||
|
||||
def test_domaine_par_vault(monkeypatch):
|
||||
from backend import agent_journal
|
||||
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_DOMAINES",
|
||||
json.dumps({"TestVault": "famille"}))
|
||||
assert agent_journal.domaine_du_vault("TestVault") == "famille"
|
||||
assert agent_journal.domaine_du_vault("AutreVault") == "pro"
|
||||
|
||||
|
||||
# ── Écritures REST (acteur « user ») ─────────────────────────────────
|
||||
|
||||
|
||||
def test_cycle_de_vie_rest(client, monkeypatch):
|
||||
serveur = _demarrer(monkeypatch)
|
||||
try:
|
||||
r = client.post("/api/file/TestVault", json={
|
||||
"path": "Journal/nouvelle.md", "content": "# Nouvelle\n"})
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.put("/api/file/TestVault/save",
|
||||
params={"path": "Journal/nouvelle.md"},
|
||||
json={"content": "# Nouvelle v2\n"})
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.patch("/api/file/TestVault", json={
|
||||
"path": "Journal/nouvelle.md", "new_name": "renommee.md"})
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.post("/api/move/TestVault", json={
|
||||
"source_path": "Journal/renommee.md", "destination_dir": "Projets"})
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.delete("/api/file/TestVault",
|
||||
params={"path": "Projets/renommee.md"})
|
||||
assert r.status_code == 200
|
||||
|
||||
vus = _attendre([
|
||||
"obsigate.note.created", "obsigate.note.edited",
|
||||
"obsigate.note.moved", "obsigate.note.deleted"])
|
||||
|
||||
env = vus["obsigate.note.created"]
|
||||
assert env["actor"] == "user"
|
||||
assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md"
|
||||
assert env["object"]["label"] == "nouvelle.md"
|
||||
assert env["object"]["url"] == "/#file=TestVault:Journal/nouvelle.md"
|
||||
|
||||
env = vus["obsigate.note.edited"]
|
||||
assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md"
|
||||
|
||||
# moved : émis deux fois (renommage puis déplacement). Les
|
||||
# envois partent en fils d'arrière-plan — l'ordre d'arrivée
|
||||
# n'est pas garanti, on compare l'ensemble des références.
|
||||
refs_deplacees = {
|
||||
json.loads(recu["corps"].decode("utf-8"))["object"]["ref"]
|
||||
for recu in _Recepteur.recus
|
||||
if json.loads(recu["corps"].decode("utf-8"))["type"]
|
||||
== "obsigate.note.moved"}
|
||||
assert refs_deplacees == {
|
||||
"fichier:TestVault/Journal/renommee.md",
|
||||
"fichier:TestVault/Projets/renommee.md"}
|
||||
|
||||
env = vus["obsigate.note.deleted"]
|
||||
assert env["object"]["ref"] == "fichier:TestVault/Projets/renommee.md"
|
||||
finally:
|
||||
serveur.shutdown()
|
||||
|
||||
|
||||
# ── Écritures par les outils / MCP (acteur « agent:mcp ») ───────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vault_outils(tmp_path, monkeypatch):
|
||||
"""Vault minimal pour la couche outils (patron test_document_tools)."""
|
||||
import backend.indexer
|
||||
|
||||
vault_dir = tmp_path / "Vault"
|
||||
vault_dir.mkdir()
|
||||
monkeypatch.setitem(
|
||||
backend.indexer.index, "Vault",
|
||||
{"name": "Vault", "path": str(vault_dir), "config": {}})
|
||||
return vault_dir
|
||||
|
||||
|
||||
def test_outils_mcp_emettent_agent_mcp(vault_outils, monkeypatch):
|
||||
from backend.tools.api import ToolContext, ToolMode, call_tool
|
||||
|
||||
serveur = _demarrer(monkeypatch)
|
||||
try:
|
||||
ctx = ToolContext(
|
||||
user={"username": "tester", "role": "admin", "vaults": ["*"]},
|
||||
mode=ToolMode.MCP, audit_enabled=False)
|
||||
|
||||
out = call_tool("create_file", ctx, {
|
||||
"vault": "Vault", "path": "par-mcp.md", "content": "# MCP\n",
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
|
||||
out = call_tool("edit_file", ctx, {
|
||||
"vault": "Vault", "path": "par-mcp.md", "content": "# MCP v2\n",
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
|
||||
out = call_tool("rename_file", ctx, {
|
||||
"vault": "Vault", "path": "par-mcp.md", "new_name": "renomme-mcp.md",
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
|
||||
out = call_tool("delete_file", ctx, {
|
||||
"vault": "Vault", "path": "renomme-mcp.md",
|
||||
}, confirm=True)
|
||||
assert out.ok
|
||||
|
||||
vus = _attendre([
|
||||
"obsigate.note.created", "obsigate.note.edited",
|
||||
"obsigate.note.moved", "obsigate.note.deleted"])
|
||||
for env in vus.values():
|
||||
assert env["actor"] == "agent:mcp"
|
||||
assert vus["obsigate.note.created"]["object"]["ref"] == \
|
||||
"fichier:Vault/par-mcp.md"
|
||||
assert vus["obsigate.note.moved"]["object"]["ref"] == \
|
||||
"fichier:Vault/renomme-mcp.md"
|
||||
finally:
|
||||
serveur.shutdown()
|
||||
|
||||
|
||||
# ── Inactivité sans clé ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_inactif_sans_cle(client, monkeypatch):
|
||||
serveur = _demarrer(monkeypatch)
|
||||
monkeypatch.delenv("AGENT_EVENEMENTS_CLE")
|
||||
try:
|
||||
from backend import agent_journal
|
||||
|
||||
assert agent_journal.actif() is False
|
||||
assert agent_journal.emettre(
|
||||
agent_journal.TYPE_CREE, "TestVault", "x.md") is False
|
||||
r = client.post("/api/file/TestVault", json={
|
||||
"path": "sans-journal.md", "content": "# Rien\n"})
|
||||
assert r.status_code == 200
|
||||
time.sleep(0.6)
|
||||
assert _Recepteur.recus == []
|
||||
finally:
|
||||
serveur.shutdown()
|
||||
|
||||
|
||||
# ── Garde-fou : watcher et indexation n'émettent jamais ─────────────
|
||||
|
||||
|
||||
def test_watcher_et_indexation_sans_evenement(client, test_vault_dir,
|
||||
monkeypatch):
|
||||
"""Une écriture disque externe (éditeur, synchro de fichiers…)
|
||||
traitée par le watcher puis l'indexeur ne produit AUCUN
|
||||
événement : seuls les appels explicites à l'API émettent."""
|
||||
from pathlib import Path
|
||||
|
||||
appels = []
|
||||
monkeypatch.setattr(
|
||||
"backend.agent_journal.emettre",
|
||||
lambda *a, **k: appels.append((a, k)) or False)
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||
|
||||
cible = Path(test_vault_dir) / "externe.md"
|
||||
cible.write_text("# Écrit hors API\n", encoding="utf-8")
|
||||
|
||||
# Chemin watcher : les gestionnaires d'événements filesystem.
|
||||
from backend.watcher import VaultEventHandler
|
||||
|
||||
boucle = asyncio.new_event_loop()
|
||||
try:
|
||||
file_attente: asyncio.Queue = asyncio.Queue()
|
||||
handler = VaultEventHandler("TestVault", file_attente, boucle)
|
||||
evt = SimpleNamespace(is_directory=False, src_path=str(cible),
|
||||
dest_path=None)
|
||||
handler.on_created(evt)
|
||||
handler.on_modified(evt)
|
||||
|
||||
# Chemin indexation : la même fonction que le watcher appelle
|
||||
# après debounce pour intégrer le fichier à l'index.
|
||||
from backend.indexer import update_single_file
|
||||
|
||||
boucle.run_until_complete(update_single_file("TestVault", "externe.md"))
|
||||
finally:
|
||||
boucle.close()
|
||||
|
||||
assert appels == []
|
||||
|
||||
|
||||
# ── Panne du récepteur : le métier n'est jamais affecté ─────────────
|
||||
|
||||
|
||||
def test_recepteur_en_panne_ne_casse_rien(client, monkeypatch):
|
||||
# Port fermé : connexion refusée dans le fil d'arrière-plan.
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
|
||||
"http://127.0.0.1:59999/api/v1/evenements")
|
||||
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||
r = client.post("/api/file/TestVault", json={
|
||||
"path": "malgre-panne.md", "content": "# OK\n"})
|
||||
assert r.status_code == 200
|
||||
r = client.put("/api/file/TestVault/save",
|
||||
params={"path": "malgre-panne.md"},
|
||||
json={"content": "# OK v2\n"})
|
||||
assert r.status_code == 200
|
||||
r = client.get("/api/file/TestVault", params={"path": "malgre-panne.md"})
|
||||
assert r.status_code == 200
|
||||
assert "OK v2" in r.json()["html"] # le GET renvoie le Markdown rendu
|
||||
@@ -251,11 +251,17 @@ class TestDependencySecurityFloors:
|
||||
|
||||
#: (paquet, plancher minimal, advisories corrigées au-dessus)
|
||||
FLOORS = {
|
||||
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
|
||||
# #224 : 6.19.0 (toolcache) frappée par 11 CVE, corrigées au plus
|
||||
# tard en 6.21.2 ; avant : PYSEC-2026-3910/3911 (fix 6.16.1).
|
||||
"pypdf": (6, 21, 2),
|
||||
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
|
||||
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
|
||||
"urllib3": (2, 8, 0), # CVE-2026-97687, CVE-2026-97688, CVE-2026-97689 (fix 2.8.0)
|
||||
"multidict": (6, 9, 1), # CVE-2026-104874 (fix 6.9.1 ; 6.7.x toolcache runner)
|
||||
# #224 : transitives / préinstallées toolcache, auditées par pip-audit.
|
||||
"brotli": (1, 2, 1), # PYSEC-2026-4322, GHSA-9v8h-qqgm-4m35 (fix 1.2.1)
|
||||
"filelock": (4, 2, 1), # CVE-2026-112940, CVE-2026-112913, CVE-2026-112907 (fix 4.2.1)
|
||||
"opentelemetry-api": (1, 45, 1), # CVE-2026-114603 (fix 1.45.1)
|
||||
}
|
||||
|
||||
def test_security_floors_are_declared(self):
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
"""Tests du coffre Secrets ObsiGate (Phase 1a / 1b, issues #17 et #18).
|
||||
|
||||
Couverture exigée par les issues :
|
||||
- chiffrement/déchiffrement, tag invalide refusé, rotations (secret, DEK,
|
||||
KEK), mode dégradé sans KEK ;
|
||||
- matrice d'autorisation, y compris admin contre coffre personnel d'autrui
|
||||
et ACL « * » qui ne donne aucun droit sur un coffre ;
|
||||
- contexte manquant refusé, octroi révoqué refusé au prochain appel,
|
||||
expiration respectée, audit complet sans valeur ;
|
||||
- lectures = métadonnées seules, jamais de valeur ni de texte chiffré.
|
||||
|
||||
Toutes les valeurs de secret utilisées ici sont fictives et ne servent
|
||||
qu'aux tests. Le store est isolé par test via OBSIGATE_DATA_DIR et le
|
||||
journal d'audit via backend.audit.AUDIT_LOG_FILE.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend import audit as audit_module
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.coffre_crypto import (
|
||||
KEKAbsenteError,
|
||||
KEKInvalideError,
|
||||
TagInvalideError,
|
||||
decrypt_secret_value,
|
||||
encrypt_secret_value,
|
||||
generate_dek,
|
||||
generate_kek_b64,
|
||||
load_kek,
|
||||
parse_kek,
|
||||
unwrap_dek,
|
||||
wrap_dek,
|
||||
)
|
||||
from backend.coffre_store import (
|
||||
CoffreExisteError,
|
||||
SecretExpireError,
|
||||
ValidationError,
|
||||
create_coffre,
|
||||
create_secret,
|
||||
distribuer_valeur,
|
||||
get_coffre_or_404,
|
||||
rotate_dek,
|
||||
rotate_kek,
|
||||
rotate_secret,
|
||||
)
|
||||
from backend.routers.coffres import router as coffres_router
|
||||
|
||||
VALEUR_TEST = "VALEUR-TEST-FICTIVE-7f3a9c-ne-pas-utiliser"
|
||||
VALEUR_TEST_2 = "VALEUR-TEST-FICTIVE-2-b4d8e1-ne-pas-utiliser"
|
||||
|
||||
# Utilisateur courant injecté dans les routes via dependency_overrides.
|
||||
_COURANT = {"user": None}
|
||||
|
||||
|
||||
def _fake_auth():
|
||||
if _COURANT["user"] is None:
|
||||
raise HTTPException(status_code=401, detail="Authentification requise")
|
||||
return _COURANT["user"]
|
||||
|
||||
|
||||
def _fake_admin():
|
||||
user = _fake_auth()
|
||||
if user.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Accès admin requis")
|
||||
return user
|
||||
|
||||
|
||||
def _user(username: str, role: str = "user", vaults: list | None = None) -> dict:
|
||||
return {
|
||||
"username": username,
|
||||
"role": role,
|
||||
"vaults": vaults if vaults is not None else [f"home-{username}"],
|
||||
"active": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env_coffre(tmp_path, monkeypatch):
|
||||
"""Environnement isolé : store, audit, KEK et feature flag."""
|
||||
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "1")
|
||||
monkeypatch.setenv("OBSIGATE_DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("OBSIGATE_KEK", generate_kek_b64())
|
||||
monkeypatch.delenv("OBSIGATE_KEK_FILE", raising=False)
|
||||
monkeypatch.setattr(audit_module, "AUDIT_LOG_FILE", tmp_path / "audit.log")
|
||||
_COURANT["user"] = _user("bruno", role="admin")
|
||||
yield tmp_path
|
||||
_COURANT["user"] = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(env_coffre):
|
||||
app = FastAPI()
|
||||
app.include_router(coffres_router)
|
||||
app.dependency_overrides[require_auth] = _fake_auth
|
||||
app.dependency_overrides[require_admin] = _fake_admin
|
||||
with TestClient(app, raise_server_exceptions=False) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
def _connecter(client, username: str, role: str = "user", vaults=None):
|
||||
_COURANT["user"] = _user(username, role=role, vaults=vaults)
|
||||
|
||||
|
||||
def _creer_coffre_secret(client, slug="personnel", nom="gitea-token", valeur=VALEUR_TEST):
|
||||
resp = client.post(
|
||||
"/api/coffres",
|
||||
json={"slug": slug, "nom": "Personnel", "perimetre": "personnel"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = client.post(
|
||||
f"/api/coffres/{slug}/secrets",
|
||||
json={
|
||||
"nom": nom,
|
||||
"type": "token",
|
||||
"service": "Gitea",
|
||||
"valeur": valeur,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _creer_consommateur_octroi(client, slug="personnel", nom="gitea-token"):
|
||||
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
consommateur = resp.json()
|
||||
jeton = consommateur["jeton"]
|
||||
resp = client.post(
|
||||
f"/api/coffres/{slug}/octrois",
|
||||
json={
|
||||
"cible_type": "consommateur",
|
||||
"cible_id": consommateur["id"],
|
||||
"secret_nom": nom,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return consommateur, jeton, resp.json()
|
||||
|
||||
|
||||
def _distribuer(client, jeton, reference="coffre:personnel/gitea-token",
|
||||
contexte_type="mission", contexte_id="mission-test-1"):
|
||||
return client.post(
|
||||
"/api/coffres/distribuer",
|
||||
json={
|
||||
"reference": reference,
|
||||
"contexte_type": contexte_type,
|
||||
"contexte_id": contexte_id,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {jeton}"},
|
||||
)
|
||||
|
||||
|
||||
def _fichiers_sans_valeur(tmp_path, valeur=VALEUR_TEST):
|
||||
for path in tmp_path.iterdir():
|
||||
if path.is_file() and path.suffix in (".json", ".log"):
|
||||
assert valeur not in path.read_text(encoding="utf-8"), path.name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Crypto (Phase 1a)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCrypto:
|
||||
def test_aller_retour_aes_gcm(self):
|
||||
kek = bytes(range(32))
|
||||
dek = generate_dek()
|
||||
wrapped = wrap_dek(dek, kek, "coffre-test")
|
||||
assert unwrap_dek(wrapped, kek, "coffre-test") == dek
|
||||
chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST)
|
||||
assert decrypt_secret_value(dek, "secret-1", 1, chiffre["nonce"], chiffre["ciphertext"]) == VALEUR_TEST
|
||||
|
||||
def test_tag_invalide_refuse(self):
|
||||
dek = generate_dek()
|
||||
chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST)
|
||||
alt = bytearray(base64.b64decode(chiffre["ciphertext"]))
|
||||
alt[-1] ^= 1
|
||||
chiffre_alt = {
|
||||
"nonce": chiffre["nonce"],
|
||||
"ciphertext": base64.b64encode(bytes(alt)).decode("ascii"),
|
||||
}
|
||||
with pytest.raises(TagInvalideError):
|
||||
decrypt_secret_value(dek, "secret-1", 1, chiffre_alt["nonce"], chiffre_alt["ciphertext"])
|
||||
|
||||
def test_aad_incorrect_refuse(self):
|
||||
dek = generate_dek()
|
||||
chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST)
|
||||
with pytest.raises(TagInvalideError):
|
||||
decrypt_secret_value(dek, "secret-2", 1, chiffre["nonce"], chiffre["ciphertext"])
|
||||
|
||||
def test_parse_kek_invalide(self, monkeypatch):
|
||||
with pytest.raises(KEKInvalideError):
|
||||
parse_kek("trop-court")
|
||||
assert len(parse_kek(generate_kek_b64())) == 32
|
||||
|
||||
def test_kek_absente(self, tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
|
||||
monkeypatch.delenv("OBSIGATE_KEK_FILE", raising=False)
|
||||
with pytest.raises(KEKAbsenteError):
|
||||
load_kek()
|
||||
|
||||
def test_kek_fichier_0600_ok_et_trop_permissif_refuse(self, tmp_path, monkeypatch):
|
||||
kek_file = tmp_path / "kek.txt"
|
||||
kek_file.write_text(generate_kek_b64(), encoding="utf-8")
|
||||
os.chmod(kek_file, 0o600)
|
||||
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
|
||||
monkeypatch.setenv("OBSIGATE_KEK_FILE", str(kek_file))
|
||||
assert len(load_kek()) == 32
|
||||
os.chmod(kek_file, 0o644)
|
||||
with pytest.raises(KEKInvalideError):
|
||||
load_kek()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Store (Phase 1a)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStore:
|
||||
def test_roundtrip_et_aucun_clair_au_repos(self, env_coffre):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST, service="Gitea")
|
||||
assert distribuer_valeur(coffre, secret) == VALEUR_TEST
|
||||
_fichiers_sans_valeur(env_coffre)
|
||||
|
||||
def test_rotation_secret(self, env_coffre):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||
secret = rotate_secret(coffre, "gitea-token", VALEUR_TEST_2)
|
||||
assert secret["version_courante"] == 2
|
||||
assert distribuer_valeur(coffre, secret) == VALEUR_TEST_2
|
||||
|
||||
def test_rotation_dek_puis_kek(self, env_coffre, monkeypatch):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||
rotate_dek("personnel")
|
||||
assert distribuer_valeur(get_coffre_or_404("personnel"), secret) == VALEUR_TEST
|
||||
nouvelle_kek = generate_kek_b64()
|
||||
assert rotate_kek(nouvelle_kek) == 1
|
||||
monkeypatch.setenv("OBSIGATE_KEK", nouvelle_kek)
|
||||
assert distribuer_valeur(get_coffre_or_404("personnel"), secret) == VALEUR_TEST
|
||||
|
||||
def test_mode_degrade_sans_kek(self, env_coffre, monkeypatch):
|
||||
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
assert coffre["dek_chiffree"] is None
|
||||
with pytest.raises(KEKAbsenteError):
|
||||
create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||
|
||||
def test_validation_et_doublons(self, env_coffre):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
with pytest.raises(ValidationError):
|
||||
create_coffre("MAUVAIS SLUG !!", "X", "bruno")
|
||||
with pytest.raises(CoffreExisteError):
|
||||
create_coffre("personnel", "Autre", "bruno")
|
||||
create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||
with pytest.raises(ValidationError):
|
||||
create_secret(
|
||||
coffre, "autre-secret", "token", VALEUR_TEST,
|
||||
note="api-key: ceci-ressemble-a-un-secret",
|
||||
)
|
||||
|
||||
def test_secret_expire_refuse_a_la_distribution(self, env_coffre):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
hier = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
||||
secret = create_secret(
|
||||
coffre, "vieux-jeton", "token", VALEUR_TEST, expiration=hier
|
||||
)
|
||||
with pytest.raises(SecretExpireError):
|
||||
distribuer_valeur(coffre, secret)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API et autorisations (Phase 1b)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestApiAutorisations:
|
||||
def test_flag_desactive_repond_503(self, client, monkeypatch):
|
||||
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
|
||||
assert client.get("/api/coffres").status_code == 503
|
||||
|
||||
def test_lectures_metadonnees_seules(self, client, env_coffre):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.get("/api/coffres/personnel/secrets")
|
||||
assert resp.status_code == 200
|
||||
corps = resp.text
|
||||
assert VALEUR_TEST not in corps
|
||||
assert "ciphertext" not in corps
|
||||
assert "nonce" not in corps
|
||||
item = resp.json()[0]
|
||||
assert item["has_value"] is True
|
||||
assert item["reference"] == "coffre:personnel/gitea-token"
|
||||
assert "valeur" not in item
|
||||
|
||||
def test_admin_ne_voit_pas_le_coffre_personnel_d_autrui(self, client):
|
||||
_creer_coffre_secret(client) # propriétaire : bruno (admin de test)
|
||||
_connecter(client, "alice", role="user")
|
||||
assert client.get("/api/coffres/personnel").status_code == 403
|
||||
assert client.get("/api/coffres/personnel/secrets").status_code == 403
|
||||
# Même un admin avec vaults=["*"] n'a aucun droit sur un coffre
|
||||
# personnel dont il n'est pas propriétaire.
|
||||
_connecter(client, "admin2", role="admin", vaults=["*"])
|
||||
assert client.get("/api/coffres/personnel").status_code == 403
|
||||
|
||||
def test_acl_etoile_ne_donne_aucun_droit_coffre(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_connecter(client, "mallory", role="user", vaults=["*"])
|
||||
assert client.get("/api/coffres/personnel/secrets").status_code == 403
|
||||
|
||||
def test_coffre_instance_reserve_admin(self, client):
|
||||
_connecter(client, "bruno", role="user")
|
||||
resp = client.post(
|
||||
"/api/coffres",
|
||||
json={"slug": "instance", "nom": "Instance", "perimetre": "instance"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
_connecter(client, "bruno", role="admin")
|
||||
resp = client.post(
|
||||
"/api/coffres",
|
||||
json={"slug": "instance", "nom": "Instance", "perimetre": "instance"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
_connecter(client, "alice", role="user")
|
||||
assert client.get("/api/coffres/instance").status_code == 403
|
||||
|
||||
def test_creation_secret_sans_kek_repond_503(self, client, monkeypatch):
|
||||
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
|
||||
resp = client.post("/api/coffres", json={"slug": "personnel", "nom": "Personnel"})
|
||||
assert resp.status_code == 200 # métadonnées créées en mode dégradé
|
||||
resp = client.post(
|
||||
"/api/coffres/personnel/secrets",
|
||||
json={"nom": "gitea-token", "type": "token", "valeur": VALEUR_TEST},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["detail"] == "kek_absente"
|
||||
|
||||
|
||||
class TestDistribution:
|
||||
def test_distribution_ok_puis_revoquee_puis_consommateur_revoque(self, client, env_coffre):
|
||||
_creer_coffre_secret(client)
|
||||
consommateur, jeton, octroi = _creer_consommateur_octroi(client)
|
||||
resp = _distribuer(client, jeton)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["valeur"] == VALEUR_TEST
|
||||
assert resp.json()["version"] == 1
|
||||
# Le jeton complet n'est jamais relisté, ni son hash (seul le
|
||||
# préfixe d'affichage, non exploitable, apparaît).
|
||||
liste = client.get("/api/coffres/consommateurs").json()
|
||||
assert jeton not in json.dumps(liste)
|
||||
assert "jeton_hash" not in json.dumps(liste)
|
||||
assert all(set(item.keys()) == {"id", "nom", "jeton_prefixe", "statut", "cree_par", "cree_le"} for item in liste)
|
||||
# Révocation de l'octroi : refusé dès le prochain appel.
|
||||
resp = client.delete(f"/api/coffres/octrois/{octroi['id']}")
|
||||
assert resp.status_code == 200
|
||||
resp = _distribuer(client, jeton)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == "octroi_revoque"
|
||||
# Révocation du consommateur : tous ses octrois tombent.
|
||||
resp = client.post(
|
||||
"/api/coffres/personnel/octrois",
|
||||
json={"cible_type": "consommateur", "cible_id": consommateur["id"], "secret_nom": "gitea-token"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = client.delete(f"/api/coffres/consommateurs/{consommateur['id']}")
|
||||
assert resp.status_code == 200
|
||||
resp = _distribuer(client, jeton)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == "consommateur_revoque"
|
||||
|
||||
def test_contexte_manquant_refuse(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_, jeton, _ = _creer_consommateur_octroi(client)
|
||||
resp = client.post(
|
||||
"/api/coffres/distribuer",
|
||||
json={"reference": "coffre:personnel/gitea-token"},
|
||||
headers={"Authorization": f"Bearer {jeton}"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["detail"] == "contexte_manquant"
|
||||
|
||||
def test_session_utilisateur_ne_distribue_pas(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_creer_consommateur_octroi(client)
|
||||
resp = _distribuer(client, "jeton-session-utilisateur-fictif")
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["detail"] == "consommateur_invalide"
|
||||
|
||||
def test_octroi_absent_et_secret_expire(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post("/api/coffres/consommateurs", json={"nom": "sans-octroi"})
|
||||
jeton = resp.json()["jeton"]
|
||||
resp = _distribuer(client, jeton)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == "octroi_absent"
|
||||
# Secret expiré : 410, même avec un octroi actif.
|
||||
_connecter(client, "bruno", role="admin")
|
||||
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
|
||||
_, jeton2, _ = _creer_consommateur_octroi(client, nom="vieux-jeton")
|
||||
resp = _distribuer(client, jeton2, reference="coffre:personnel/vieux-jeton")
|
||||
assert resp.status_code == 410
|
||||
assert resp.json()["detail"] == "secret_expire"
|
||||
|
||||
def test_rotation_change_la_valeur_distribuee(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_, jeton, _ = _creer_consommateur_octroi(client)
|
||||
resp = client.post(
|
||||
"/api/coffres/personnel/secrets/gitea-token/rotation",
|
||||
json={"valeur": VALEUR_TEST_2},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["version"] == 2
|
||||
resp = _distribuer(client, jeton)
|
||||
assert resp.json()["valeur"] == VALEUR_TEST_2
|
||||
|
||||
def test_suppression_casse_les_octrois(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_, jeton, _ = _creer_consommateur_octroi(client)
|
||||
resp = client.delete("/api/coffres/personnel/secrets/gitea-token")
|
||||
assert resp.status_code == 200
|
||||
resp = _distribuer(client, jeton)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == "octroi_absent"
|
||||
|
||||
def test_octroi_par_defaut_90_jours(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_, _, octroi = _creer_consommateur_octroi(client)
|
||||
expiration = datetime.fromisoformat(octroi["expiration"])
|
||||
delta = expiration - datetime.now(timezone.utc)
|
||||
assert 89 <= delta.days <= 91
|
||||
|
||||
def test_audit_sans_valeur_et_journal(self, client, env_coffre):
|
||||
_creer_coffre_secret(client)
|
||||
_, jeton, _ = _creer_consommateur_octroi(client)
|
||||
assert _distribuer(client, jeton).status_code == 200
|
||||
_fichiers_sans_valeur(env_coffre)
|
||||
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
|
||||
assert "distribution_ok" in audit
|
||||
assert VALEUR_TEST not in audit
|
||||
resp = client.get("/api/coffres/personnel/journal")
|
||||
assert resp.status_code == 200
|
||||
actions = [e["action"] for e in resp.json()]
|
||||
assert "secret_cree" in actions
|
||||
assert "distribution_ok" in actions
|
||||
assert "valeur" not in json.dumps(resp.json())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — Migration api_keys.json (issue #20)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMigrationApiKeys:
|
||||
def _ecrire_api_keys(self, tmp_path):
|
||||
(tmp_path / "api_keys.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"DEEPSEEK_API_KEY": VALEUR_TEST,
|
||||
"OBSIGATE_GITEA_TOKEN": VALEUR_TEST_2,
|
||||
"OBSIGATE_GITEA_URL": "https://example.invalid",
|
||||
"MISTRAL_API_KEY": "",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def test_apercu_import_purge(self, client, env_coffre):
|
||||
self._ecrire_api_keys(env_coffre)
|
||||
resp = client.get("/api/coffres/migration/api-keys")
|
||||
assert resp.status_code == 200, resp.text
|
||||
# Aperçu = métadonnées seules, jamais une valeur.
|
||||
assert VALEUR_TEST not in resp.text
|
||||
assert VALEUR_TEST_2 not in resp.text
|
||||
candidats = {c["nom"]: c for c in resp.json()["candidats"]}
|
||||
assert candidats["deepseek_api_key"]["has_value"] is True
|
||||
assert candidats["mistral_api_key"]["has_value"] is False
|
||||
assert "obsigate_gitea_url" not in candidats # URL, pas un secret
|
||||
# Import : ne supprime rien dans api_keys.json.
|
||||
resp = client.post("/api/coffres/migration/api-keys/import", json={})
|
||||
assert resp.status_code == 200, resp.text
|
||||
resultat = resp.json()
|
||||
assert resultat["coffre"] == "obsigate-interne"
|
||||
assert "deepseek_api_key" in resultat["migres"]
|
||||
assert "obsigate_gitea_token" in resultat["migres"]
|
||||
assert "mistral_api_key" in resultat["sans_valeur"]
|
||||
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
|
||||
assert cles["DEEPSEEK_API_KEY"] == VALEUR_TEST
|
||||
# Les secrets migrés sont visibles en métadonnées et distribuables.
|
||||
resp = client.get("/api/coffres/obsigate-interne/secrets")
|
||||
assert resp.status_code == 200
|
||||
assert VALEUR_TEST not in resp.text
|
||||
assert any(s["nom"] == "deepseek_api_key" and s["has_value"] for s in resp.json())
|
||||
_, jeton, _ = _creer_consommateur_octroi(
|
||||
client, slug="obsigate-interne", nom="deepseek_api_key"
|
||||
)
|
||||
resp = _distribuer(
|
||||
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["valeur"] == VALEUR_TEST
|
||||
# Purge sans confirmation : refusée, fichier intact.
|
||||
resp = client.post(
|
||||
"/api/coffres/migration/api-keys/purger",
|
||||
json={"confirmer": False, "noms": ["deepseek_api_key"]},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
|
||||
assert "DEEPSEEK_API_KEY" in cles
|
||||
# Purge confirmée : seules les clés listées disparaissent.
|
||||
resp = client.post(
|
||||
"/api/coffres/migration/api-keys/purger",
|
||||
json={"confirmer": True, "noms": ["deepseek_api_key", "obsigate_gitea_token"]},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert set(resp.json()["supprimes"]) == {
|
||||
"deepseek_api_key",
|
||||
"obsigate_gitea_token",
|
||||
}
|
||||
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
|
||||
assert "DEEPSEEK_API_KEY" not in cles
|
||||
assert "OBSIGATE_GITEA_TOKEN" not in cles
|
||||
assert cles["OBSIGATE_GITEA_URL"] == "https://example.invalid"
|
||||
# Audit sans valeur.
|
||||
_fichiers_sans_valeur(env_coffre)
|
||||
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
|
||||
assert "migration_api_keys" in audit
|
||||
assert "migration_api_keys_purge" in audit
|
||||
|
||||
def test_import_ne_ecrase_pas_existant(self, client, env_coffre):
|
||||
self._ecrire_api_keys(env_coffre)
|
||||
assert client.post("/api/coffres/migration/api-keys/import", json={}).status_code == 200
|
||||
(env_coffre / "api_keys.json").write_text(
|
||||
json.dumps({"DEEPSEEK_API_KEY": VALEUR_TEST_2}), encoding="utf-8"
|
||||
)
|
||||
resp = client.post("/api/coffres/migration/api-keys/import", json={})
|
||||
assert resp.status_code == 200
|
||||
assert "deepseek_api_key" in resp.json()["deja_existants"]
|
||||
_, jeton, _ = _creer_consommateur_octroi(
|
||||
client, slug="obsigate-interne", nom="deepseek_api_key"
|
||||
)
|
||||
resp = _distribuer(
|
||||
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
|
||||
)
|
||||
assert resp.json()["valeur"] == VALEUR_TEST # valeur d'origine conservée
|
||||
|
||||
def test_import_sans_kek_repond_503(self, client, env_coffre, monkeypatch):
|
||||
# Une clé à migrer est nécessaire : sans valeur à chiffrer,
|
||||
# l'import réussit trivialement même sans KEK (mode dégradé).
|
||||
self._ecrire_api_keys(env_coffre)
|
||||
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
|
||||
resp = client.post("/api/coffres/migration/api-keys/import", json={})
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["detail"] == "kek_absente"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — Restauration sur instance vierge (issue #20)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRestaurationVierge:
|
||||
def test_restauration_meme_kek_distribue(self, env_coffre, tmp_path):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||
assert distribuer_valeur(coffre, secret) == VALEUR_TEST
|
||||
# Simulation d'une instance vierge : seuls les deux fichiers sont
|
||||
# copiés, la KEK (variable d'environnement) est inchangée.
|
||||
restaure = tmp_path / "restaure"
|
||||
restaure.mkdir()
|
||||
for nom in ("coffres.json", "coffres_valeurs.json"):
|
||||
shutil.copy(env_coffre / nom, restaure / nom)
|
||||
coffre_r = get_coffre_or_404("personnel", restaure)
|
||||
assert distribuer_valeur(coffre_r, secret, restaure) == VALEUR_TEST
|
||||
for nom in ("coffres.json", "coffres_valeurs.json"):
|
||||
assert VALEUR_TEST not in (restaure / nom).read_text(encoding="utf-8")
|
||||
|
||||
def test_restauration_mauvaise_kek_refuse(self, env_coffre, tmp_path, monkeypatch):
|
||||
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||
restaure = tmp_path / "restaure"
|
||||
restaure.mkdir()
|
||||
for nom in ("coffres.json", "coffres_valeurs.json"):
|
||||
shutil.copy(env_coffre / nom, restaure / nom)
|
||||
# Une autre KEK ne déchiffre rien : tag GCM invalide, pas de valeur.
|
||||
monkeypatch.setenv("OBSIGATE_KEK", generate_kek_b64())
|
||||
coffre_r = get_coffre_or_404("personnel", restaure)
|
||||
with pytest.raises(TagInvalideError):
|
||||
distribuer_valeur(coffre_r, secret, restaure)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 3 — Contexte Spark imbriqué (issue #21)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDistributionSpark:
|
||||
def test_contexte_nested_mission(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
_, jeton, _ = _creer_consommateur_octroi(client)
|
||||
resp = client.post(
|
||||
"/api/coffres/distribuer",
|
||||
json={
|
||||
"reference": "coffre:personnel/gitea-token",
|
||||
"contexte": {"type": "mission", "id": "mission-test-2"},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {jeton}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["valeur"] == VALEUR_TEST
|
||||
|
||||
def test_contexte_nested_sans_octroi_acces_requis(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
|
||||
jeton = resp.json()["jeton"]
|
||||
resp = client.post(
|
||||
"/api/coffres/distribuer",
|
||||
json={
|
||||
"reference": "coffre:personnel/gitea-token",
|
||||
"contexte": {"type": "mission", "id": "mission-test-3"},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {jeton}"},
|
||||
)
|
||||
# Côté Spark, ce code se traduit par l'état acces_requis.
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == "octroi_absent"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 4 — Consommateur + octrois en un appel (issue #22)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConsommateurAvecOctrois:
|
||||
def test_avec_octrois_distribue(self, client, env_coffre):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post(
|
||||
"/api/coffres/consommateurs/avec-octrois",
|
||||
json={
|
||||
"nom": "tiktak",
|
||||
"octrois": [{"coffre_slug": "personnel", "secret_nom": "gitea-token"}],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
corps = resp.json()
|
||||
assert corps["nom"] == "tiktak"
|
||||
assert corps["jeton"].startswith("csk_")
|
||||
assert len(corps["octrois"]) == 1
|
||||
assert corps["erreurs"] == []
|
||||
resp = _distribuer(client, corps["jeton"])
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["valeur"] == VALEUR_TEST
|
||||
liste = client.get("/api/coffres/consommateurs").json()
|
||||
assert corps["jeton"] not in json.dumps(liste)
|
||||
assert "jeton_hash" not in json.dumps(liste)
|
||||
|
||||
def test_avec_octrois_erreur_signalee_sans_annuler(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post(
|
||||
"/api/coffres/consommateurs/avec-octrois",
|
||||
json={
|
||||
"nom": "flowdeck",
|
||||
"octrois": [
|
||||
{"coffre_slug": "personnel", "secret_nom": "gitea-token"},
|
||||
{"coffre_slug": "inexistant", "secret_nom": "gitea-token"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert len(resp.json()["octrois"]) == 1
|
||||
assert len(resp.json()["erreurs"]) == 1
|
||||
assert resp.json()["erreurs"][0]["coffre_slug"] == "inexistant"
|
||||
@@ -0,0 +1,148 @@
|
||||
"""BUG-118 — barre d'apps commune en mobile (Fusion ACC-1, #215).
|
||||
|
||||
Retours de Bruno (2026-10-21) sur téléphone : la barre débordait
|
||||
(pastilles domaines et onglets entiers affichés) et son bouton ☰
|
||||
ne faisait rien — il cliquait le bouton bureau
|
||||
``sidebar-toggle-btn`` au lieu du vrai hamburger mobile
|
||||
``hamburger-btn``. Correctifs verrouillés ici : CSS commune v0.1.2
|
||||
(palier 700 px resserré selon la spec) et rail contextuel dans
|
||||
index.html (mobile ≤ 768 px → hamburger-btn).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parent.parent / "frontend"
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return (FRONTEND / "draco-barre.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _index() -> str:
|
||||
return (FRONTEND / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_palier_mobile_resserre_v012():
|
||||
css = _css()
|
||||
assert "@media (max-width: 900px)" in css
|
||||
# Seul l'onglet de l'app courante reste visible en mobile.
|
||||
assert '.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"]' in css
|
||||
# Domaines et boutons icônes (rail, menu) masqués en mobile.
|
||||
bloc = css.split("@media (max-width: 900px)", 1)[1]
|
||||
assert ".draco-domaines" in bloc
|
||||
assert "display: none" in bloc
|
||||
|
||||
|
||||
def test_menu_des_applications_par_la_marque():
|
||||
# v0.1.3 : la marque ✦ ouvre le menu des 4 applications
|
||||
# (changement d'app en mobile), logo à 24 px.
|
||||
html = _index()
|
||||
assert 'data-act="apps"' in html
|
||||
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||
assert "draco-apps-menu" in js
|
||||
assert "font-size: 24px" in _css()
|
||||
|
||||
|
||||
def test_panneau_spark_integre_218():
|
||||
# #218 : le bouton Spark de la barre ouvre le panneau de l'agent
|
||||
# commun (mêmes assets partagés que TikTak / Flowdeck / Kanbante).
|
||||
html = _index()
|
||||
assert (FRONTEND / "draco-panneau.js").is_file()
|
||||
assert (FRONTEND / "draco-panneau.css").is_file()
|
||||
assert '<link rel="stylesheet" href="/static/draco-panneau.css" />' in html
|
||||
assert '<script defer src="/static/draco-panneau.js"></script>' in html
|
||||
# L'action spark bascule sur le panneau — plus le message d'attente.
|
||||
assert "window.DRACO_PANNEAU.toggle()" in html
|
||||
assert "arrive en phase 4 du programme Fusion" not in html
|
||||
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||
assert "https://agent.dracodev.net" in panneau
|
||||
|
||||
|
||||
def test_panneau_bouton_console_221():
|
||||
# #221 : le panneau Spark (asset vendangé, octet-identique dans
|
||||
# les 4 apps) porte un bouton « Console » qui mène à la console
|
||||
# web du service, sur la discussion courante (?disc=) et avec
|
||||
# l'app d'origine (?app= déduite du sous-domaine : og → obsigate).
|
||||
# Révisé par #222 : l'ouverture en nouvel onglet a disparu (la
|
||||
# console s'ouvre en surimpression, voir le test #222) ; le lien
|
||||
# profond, lui, subsiste via urlConsole().
|
||||
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||
assert 'data-disc="console"' in panneau
|
||||
assert "ouvrirConsole" in panneau
|
||||
assert "urlConsole" in panneau
|
||||
assert '"disc=" + encodeURIComponent(id)' in panneau
|
||||
assert '"app=" + encodeURIComponent(app)' in panneau
|
||||
assert 'og: "obsigate"' in panneau
|
||||
assert 'window.open(' not in panneau
|
||||
|
||||
|
||||
def test_panneau_console_surimpression_222():
|
||||
# #222 : le bouton « Console ⤢ » ouvre la console dans la même
|
||||
# page, en surimpression née à la largeur du panneau et étendue
|
||||
# à toute la fenêtre (transition sur la largeur), avec deux
|
||||
# sorties : « Mode sidebar » et « Fermer l'agent ». L'URL de
|
||||
# l'iframe garde le lien profond de #221 (urlConsole()).
|
||||
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||
assert "Console ⤢" in panneau
|
||||
assert "draco-console-sur" in panneau
|
||||
assert "assurerSurimpression" in panneau
|
||||
assert "draco-console-styles" in panneau
|
||||
assert 'surCadre.src = urlConsole()' in panneau
|
||||
assert 'surEl.style.width = "100%"' in panneau
|
||||
assert 'data-dc="sidebar"' in panneau
|
||||
assert "Mode sidebar" in panneau
|
||||
assert "modeSidebarConsole" in panneau
|
||||
assert 'data-dc="fermer"' in panneau
|
||||
assert "Fermer l'agent" in panneau
|
||||
assert "fermerAgentConsole" in panneau
|
||||
|
||||
|
||||
def test_csp_ouvre_le_service_d_agents_218():
|
||||
# Le panneau parle au service d'agents : la CSP doit nommer son
|
||||
# hôte en connect-src, sans ouvrir de générique supplémentaire.
|
||||
# #223 : l'origine n'est plus un littéral dans main.py — elle est
|
||||
# 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)
|
||||
assert "agent_origine" in main
|
||||
assert "{agent}" in ligne
|
||||
|
||||
|
||||
def test_urls_ecosysteme_via_injection_hote_223():
|
||||
# #223 : panneau Spark, liens TikTak et onglets des apps lisent
|
||||
# window.DRACO_URLS (injecté par l'hôte/backend depuis sa config) ;
|
||||
# les littéraux restants ne sont que les défauts officiels en repli.
|
||||
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||
assert "window.DRACO_URLS" in panneau
|
||||
assert "DRACO_URLS.agent" in panneau
|
||||
liens = (FRONTEND / "draco-liens.js").read_text(encoding="utf-8")
|
||||
assert "window.DRACO_URLS" in liens
|
||||
assert "baseTikTak()" in liens
|
||||
html = _index()
|
||||
assert "window.DRACO_URLS" in html
|
||||
assert "DRACO_URLS[a.dataset.app]" in html
|
||||
|
||||
|
||||
def test_rail_mobile_vers_le_vrai_hamburger():
|
||||
html = _index()
|
||||
assert 'matchMedia("(max-width: 768px)")' in html
|
||||
assert '"hamburger-btn"' in html
|
||||
assert '"sidebar-toggle-btn"' in html
|
||||
|
||||
|
||||
def test_panneau_version_service_affichee_225():
|
||||
# #225 : le pied du panneau Spark (mode sidebar) affiche la
|
||||
# version du service d'agents, comme la console pleine page.
|
||||
# La version est lue en direct dans la santé du service
|
||||
# (GET /healthz de l'origine injectée par l'hôte), à l'ouverture
|
||||
# du panneau ; jamais un littéral de version dans l'asset, et
|
||||
# source injoignable = ligne laissée masquée (rien d'affiché).
|
||||
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||
assert "draco-panneau-version" in panneau
|
||||
assert "chargerVersion" in panneau
|
||||
assert 'API + "/healthz"' in panneau
|
||||
assert '"Service d\'agents v" + versionConnue' in panneau
|
||||
# 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
|
||||
@@ -0,0 +1,184 @@
|
||||
"""URLs publiques de l'écosystème par configuration — #223 (dev/prod).
|
||||
|
||||
Règle : aucun nom public de l'écosystème n'est figé dans le code
|
||||
vivant. Défauts = noms **officiels** (production future) ; le dev
|
||||
déclare ses noms en ``-dev`` dans son seul ``.env``
|
||||
(``DRACO_*_URL`` / ``AGENT_EVENEMENTS_URL``). On vérifie ici la
|
||||
config centralisée, la dérivation de l'URL d'événements du journal,
|
||||
la reconnaissance des liens profonds des deux environnements et la
|
||||
reconstruction des URL d'affichage des références croisées.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from backend import draco_config
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
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",
|
||||
"AGENT_EVENEMENTS_URL",
|
||||
):
|
||||
monkeypatch.delenv(nom, raising=False)
|
||||
return monkeypatch
|
||||
|
||||
|
||||
def test_defauts_officiels_sans_dev(env_propre):
|
||||
assert draco_config.agent_url() == "https://agent.dracodev.net"
|
||||
assert draco_config.app_url("tiktak") == "https://tiktak.dracodev.net"
|
||||
assert draco_config.app_url("obsigate") == "https://og.dracodev.net"
|
||||
assert draco_config.app_url("flowdeck") == "https://flowdeck.dracodev.net"
|
||||
assert draco_config.app_url("kanbante") == "https://kanbante.dracodev.net"
|
||||
assert draco_config.app_url("gitea") == "https://git.dracodev.net"
|
||||
for url in draco_config.urls_publiques().values():
|
||||
assert "-dev" not in url
|
||||
|
||||
|
||||
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)."""
|
||||
env_propre.setenv(
|
||||
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
|
||||
assert draco_config.agent_url() == "https://agent-dev.dracodev.net"
|
||||
assert draco_config.agent_origine() == "https://agent-dev.dracodev.net"
|
||||
|
||||
|
||||
def test_agent_explicite_prime(env_propre):
|
||||
env_propre.setenv(
|
||||
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
|
||||
env_propre.setenv("DRACO_AGENT_URL", "https://agent.example.net/")
|
||||
assert draco_config.agent_url() == "https://agent.example.net"
|
||||
|
||||
|
||||
# ------------------------------------------------- BUG-120 : dérivation sœurs
|
||||
|
||||
def test_soeurs_derivees_depuis_agent_dev(env_propre):
|
||||
"""BUG-120 : avec **seul** ``AGENT_EVENEMENTS_URL`` en ``-dev``
|
||||
posé (aucune ``DRACO_<APP>_URL``), les URL sœurs servies suivent
|
||||
l'environnement de l'agent ; Gitea ne bouge jamais."""
|
||||
env_propre.setenv(
|
||||
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
|
||||
urls = draco_config.urls_publiques()
|
||||
assert urls["agent"] == "https://agent-dev.dracodev.net"
|
||||
assert urls["obsigate"] == "https://og-dev.dracodev.net"
|
||||
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
|
||||
assert urls["tiktak"] == "https://tiktak-dev.dracodev.net"
|
||||
assert urls["kanbante"] == "https://kanbante-dev.dracodev.net"
|
||||
assert urls["gitea"] == "https://git.dracodev.net"
|
||||
|
||||
|
||||
def test_soeurs_derivees_depuis_draco_agent_url(env_propre):
|
||||
"""BUG-120 : la dérivation lit l'agent via ``agent_url()`` — un
|
||||
``DRACO_AGENT_URL`` en ``-dev`` suffit aussi."""
|
||||
env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
|
||||
assert draco_config.app_url("tiktak") == "https://tiktak-dev.dracodev.net"
|
||||
assert draco_config.app_url("obsigate") == "https://og-dev.dracodev.net"
|
||||
|
||||
|
||||
def test_soeur_explicite_prime_sur_derivation(env_propre):
|
||||
"""BUG-120 : une ``DRACO_<APP>_URL`` explicite gagne toujours sur
|
||||
la dérivation d'environnement ; les autres sœurs dérivent."""
|
||||
env_propre.setenv(
|
||||
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
|
||||
env_propre.setenv("DRACO_TIKTAK_URL", "https://taches.example.net/")
|
||||
assert draco_config.app_url("tiktak") == "https://taches.example.net"
|
||||
assert draco_config.app_url("flowdeck") == "https://flowdeck-dev.dracodev.net"
|
||||
assert draco_config.app_url("kanbante") == "https://kanbante-dev.dracodev.net"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("evenements", [
|
||||
"https://agent.example.net/api/v1/evenements", # autre domaine
|
||||
"https://agent-dev.example.com/api/v1/evenements", # suffixe hors dracodev.net
|
||||
"http://deepin.lab.home:8790/api/v1/evenements", # nom LAN + port
|
||||
"http://192.168.30.101:8790/api/v1/evenements", # IP + port
|
||||
"https://agent-dev.dracodev.net:8790/api/v1/evenements", # port exotique
|
||||
])
|
||||
def test_agent_hors_motif_aucune_derivation(env_propre, evenements):
|
||||
"""BUG-120 : hôte d'agent hors motif ``agent<SUFFIXE>.dracodev.net``
|
||||
→ **aucune dérivation** (jamais de lien deviné), défauts officiels."""
|
||||
env_propre.setenv("AGENT_EVENEMENTS_URL", evenements)
|
||||
urls = draco_config.urls_publiques()
|
||||
assert urls["obsigate"] == "https://og.dracodev.net"
|
||||
assert urls["flowdeck"] == "https://flowdeck.dracodev.net"
|
||||
assert urls["tiktak"] == "https://tiktak.dracodev.net"
|
||||
assert urls["kanbante"] == "https://kanbante.dracodev.net"
|
||||
assert urls["gitea"] == "https://git.dracodev.net"
|
||||
|
||||
|
||||
def test_agent_prod_suffixe_vide_defauts_officiels(env_propre):
|
||||
"""BUG-120 : hôte d'agent prod (suffixe vide) → les sœurs dérivées
|
||||
sont exactement les défauts officiels."""
|
||||
env_propre.setenv(
|
||||
"AGENT_EVENEMENTS_URL", "https://agent.dracodev.net/api/v1/evenements")
|
||||
for app, defaut in draco_config.DEFAUTS.items():
|
||||
assert draco_config.app_url(app) == defaut
|
||||
|
||||
|
||||
def test_journal_url_suit_la_config(env_propre):
|
||||
from backend import agent_journal
|
||||
|
||||
assert agent_journal._url() == "https://agent.dracodev.net/api/v1/evenements"
|
||||
env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
|
||||
assert agent_journal._url() == "https://agent-dev.dracodev.net/api/v1/evenements"
|
||||
env_propre.setenv(
|
||||
"AGENT_EVENEMENTS_URL", "https://autre.example.net/api/v1/evenements")
|
||||
assert agent_journal._url() == "https://autre.example.net/api/v1/evenements"
|
||||
|
||||
|
||||
# ------------------------------------------------------------ liens profonds
|
||||
|
||||
def test_parse_hotes_configures(env_propre):
|
||||
"""Un lien collé depuis l'environnement dev (hôtes déclarés dans
|
||||
son .env) est reconnu, comme un lien officiel."""
|
||||
from backend.external_refs import parse_deep_link
|
||||
|
||||
env_propre.setenv("DRACO_TIKTAK_URL", "https://tiktak-dev.dracodev.net")
|
||||
env_propre.setenv("DRACO_OBSIGATE_URL", "https://og-dev.dracodev.net")
|
||||
assert parse_deep_link("https://tiktak-dev.dracodev.net/?task=5") == (
|
||||
"tiktak", "task", "5", "TikTak · tâche 5")
|
||||
assert parse_deep_link("https://og-dev.dracodev.net/#file=v:Dossier/Note.md") == (
|
||||
"obsigate", "note", "v:Dossier/Note.md", "ObsiGate · Note.md")
|
||||
# L'officiel reste reconnu en parallèle (lien venu de la prod).
|
||||
assert parse_deep_link("https://tiktak.dracodev.net/?task=5") is not None
|
||||
|
||||
|
||||
def test_url_affichage_reconstruite_depuis_config(env_propre, tmp_path):
|
||||
"""#223 : la référence stockée garde son URL absolue d'origine
|
||||
(aucune migration), mais l'URL **servie** est reconstruite depuis
|
||||
la config courante."""
|
||||
from backend.external_refs import add_ref, build_deep_link, list_refs
|
||||
|
||||
env_propre.setenv("OBSIGATE_EXTERNAL_REFS_DIR", str(tmp_path / "refs"))
|
||||
assert build_deep_link("tiktak", "task", "42") == \
|
||||
"https://tiktak.dracodev.net/?task=42"
|
||||
assert build_deep_link("kanbante", "card", "Projets/obsigate#7") == \
|
||||
"https://git.dracodev.net/Projets/obsigate/issues/7"
|
||||
assert build_deep_link("obsigate", "note", "v:Dossier/Note.md") == \
|
||||
"https://og.dracodev.net/#file=v:Dossier%2FNote.md"
|
||||
|
||||
env_propre.setenv("DRACO_TIKTAK_URL", "https://tiktak-dev.dracodev.net")
|
||||
ref = add_ref("alice", "Vault", "N.md", "tiktak", "task", "42",
|
||||
"https://tiktak.dracodev.net/?task=42", "TikTak · tâche 42")
|
||||
assert ref["target_url"] == "https://tiktak.dracodev.net/?task=42" # stocké tel quel
|
||||
servies = list_refs("alice", "Vault", "N.md")
|
||||
assert servies[0]["target_url"] == "https://tiktak-dev.dracodev.net/?task=42"
|
||||
|
||||
|
||||
# ------------------------------------------------------------ injection hôte
|
||||
|
||||
def test_index_injecte_draco_urls(client, env_propre):
|
||||
"""L'hôte sert index.html avec window.DRACO_URLS issu de sa config.
|
||||
|
||||
BUG-120 : l'agent en ``-dev`` entraîne les sœurs en ``-dev``
|
||||
(dérivation d'environnement) — avant, ``tiktak`` restait en prod.
|
||||
"""
|
||||
env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert "window.DRACO_URLS" in resp.text
|
||||
assert '"agent": "https://agent-dev.dracodev.net"' in resp.text
|
||||
assert '"tiktak": "https://tiktak-dev.dracodev.net"' in resp.text
|
||||
assert '"gitea": "https://git.dracodev.net"' in resp.text
|
||||
@@ -185,6 +185,41 @@ def test_sso_authorize_liste_blanche(client):
|
||||
assert r.headers["location"] == "/" # refusé, pas de relais
|
||||
|
||||
|
||||
# ── Validation locale (ACC-3.2, #9) ──────────────────────────────────────
|
||||
|
||||
def test_me_avec_cookie_draco_seul(admin_client):
|
||||
# Aucune session native : le cookie partagé suffit (sub = id).
|
||||
token, _jti, _exp = draco_session.issue_token(USER)
|
||||
admin_client.cookies.set("draco_session", token)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["username"] == "admin"
|
||||
|
||||
|
||||
def test_me_draco_revoque_refuse(admin_client):
|
||||
token, jti, exp = draco_session.issue_token(USER)
|
||||
draco_session.revoke(jti, exp)
|
||||
admin_client.cookies.set("draco_session", token)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_me_draco_sub_inconnu_refuse(admin_client):
|
||||
token, _j, _e = draco_session.issue_token(
|
||||
{"id": "personne-99", "username": "x", "display_name": "X"})
|
||||
admin_client.cookies.set("draco_session", token)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_me_draco_inerte_sans_secret(admin_client, monkeypatch):
|
||||
token, _j, _e = draco_session.issue_token(USER)
|
||||
monkeypatch.delenv("DRACO_SESSION_SECRET")
|
||||
admin_client.cookies.set("draco_session", token)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch):
|
||||
token, jti, exp = draco_session.issue_token(USER)
|
||||
draco_session.revoke(jti, exp)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests de la recherche fédérée ObsiGate (fusion #68).
|
||||
|
||||
Fonctions pures de backend/recherche_cors.py uniquement : aucune base,
|
||||
aucun serveur.
|
||||
"""
|
||||
|
||||
from backend.recherche_cors import (
|
||||
cors_headers_for,
|
||||
is_dev_host,
|
||||
is_prod_host,
|
||||
origin_of,
|
||||
preflight_headers,
|
||||
sister_origins,
|
||||
)
|
||||
|
||||
RESOLVED_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",
|
||||
"agent": "https://agent-dev.dracodev.net",
|
||||
"gitea": "https://git.dracodev.net",
|
||||
}
|
||||
|
||||
|
||||
def test_is_dev_host():
|
||||
assert is_dev_host("og-dev.dracodev.net") is True
|
||||
assert is_dev_host("og.dracodev.net") is False
|
||||
assert is_dev_host("x-dev.dracodev.net.evil") is False
|
||||
assert is_prod_host("og.dracodev.net") is True
|
||||
assert is_prod_host("og-dev.dracodev.net") is False
|
||||
|
||||
|
||||
def test_origin_of():
|
||||
assert origin_of("https://og-dev.dracodev.net") == "https://og-dev.dracodev.net"
|
||||
assert origin_of("https://og-dev.dracodev.net/chemin") == "https://og-dev.dracodev.net"
|
||||
assert origin_of("{{URL_OBSIGATE}}") is None
|
||||
|
||||
|
||||
def test_sister_origins_dev():
|
||||
assert sister_origins(RESOLVED_DEV) == {
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
}
|
||||
|
||||
|
||||
def test_sister_origins_ajoute_env_de_la_requete():
|
||||
assert "https://og-dev.dracodev.net" in sister_origins(
|
||||
{}, request_host="og-dev.dracodev.net"
|
||||
)
|
||||
assert "https://og-dev.dracodev.net" not in sister_origins(
|
||||
{}, request_host="og.dracodev.net"
|
||||
)
|
||||
|
||||
|
||||
def test_cors_headers():
|
||||
headers = cors_headers_for("https://tiktak-dev.dracodev.net", RESOLVED_DEV)
|
||||
assert headers["Access-Control-Allow-Origin"] == "https://tiktak-dev.dracodev.net"
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None
|
||||
assert cors_headers_for("https://og.dracodev.net", RESOLVED_DEV) is None
|
||||
|
||||
|
||||
def test_preflight_headers():
|
||||
headers = preflight_headers("https://flowdeck-dev.dracodev.net", RESOLVED_DEV)
|
||||
assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS"
|
||||
assert preflight_headers("https://evil.example", RESOLVED_DEV) is None
|
||||
@@ -72,3 +72,59 @@ def test_security_headers_present(client):
|
||||
csp = resp.headers.get("content-security-policy", "")
|
||||
assert "object-src 'none'" in csp
|
||||
assert "frame-ancestors 'self'" in csp
|
||||
|
||||
|
||||
def _csp_directives(csp: str) -> dict[str, list[str]]:
|
||||
"""Découpe une politique CSP en ``{directive: [sources]}``."""
|
||||
directives: dict[str, list[str]] = {}
|
||||
for part in csp.split(";"):
|
||||
tokens = part.split()
|
||||
if tokens:
|
||||
directives[tokens[0]] = tokens[1:]
|
||||
return directives
|
||||
|
||||
|
||||
def test_csp_frame_src_allows_agent_console(client, monkeypatch):
|
||||
"""BUG-119 : le « mode console » du panneau Spark charge la console du
|
||||
service d'agents (``https://agent.dracodev.net``) dans une iframe —
|
||||
``frame-src`` doit autoriser cette origine, sinon le navigateur bloque
|
||||
le chargement (« This content is blocked »). Les sources historiques
|
||||
(``'self'`` et ``blob:``, visualiseur PDF) restent en place et aucune
|
||||
autre origine n'est admise.
|
||||
|
||||
#223 : la configuration est neutralisée ici (défaut officiel) —
|
||||
l'origine n'est plus figée dans le code, voir le test via config
|
||||
ci-dessous."""
|
||||
monkeypatch.delenv("DRACO_AGENT_URL", raising=False)
|
||||
monkeypatch.delenv("AGENT_EVENEMENTS_URL", raising=False)
|
||||
resp = client.get("/api/health")
|
||||
csp = resp.headers.get("content-security-policy", "")
|
||||
directives = _csp_directives(csp)
|
||||
assert directives.get("frame-src") == [
|
||||
"'self'",
|
||||
"blob:",
|
||||
"https://agent.dracodev.net",
|
||||
]
|
||||
assert "https://agent.dracodev.net" in directives.get("connect-src", [])
|
||||
|
||||
|
||||
def test_csp_agent_origine_via_config(client, monkeypatch):
|
||||
"""#223 : ``frame-src``/``connect-src`` suivent l'origine de l'agent
|
||||
**configurée** — dérivée d'``AGENT_EVENEMENTS_URL`` (config réelle
|
||||
du poste, noms en ``-dev`` en dev), ou de ``DRACO_AGENT_URL`` qui
|
||||
prime. Aucun nom d'environnement n'est codé en dur."""
|
||||
monkeypatch.delenv("DRACO_AGENT_URL", raising=False)
|
||||
monkeypatch.setenv(
|
||||
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
|
||||
resp = client.get("/api/health")
|
||||
directives = _csp_directives(resp.headers.get("content-security-policy", ""))
|
||||
assert directives.get("frame-src") == [
|
||||
"'self'", "blob:", "https://agent-dev.dracodev.net"]
|
||||
assert "https://agent-dev.dracodev.net" in directives.get("connect-src", [])
|
||||
assert "https://agent.dracodev.net" not in directives.get("connect-src", [])
|
||||
|
||||
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent.example.net")
|
||||
resp = client.get("/api/health")
|
||||
directives = _csp_directives(resp.headers.get("content-security-policy", ""))
|
||||
assert directives.get("frame-src") == [
|
||||
"'self'", "blob:", "https://agent.example.net"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Validates:
|
||||
- Theme JSON structure validity
|
||||
- All 15 built-in themes have required keys and valid CSS colors
|
||||
- All 16 built-in themes have required keys and valid CSS colors
|
||||
- High-contrast and sepia mode generators produce valid CSS vars
|
||||
- Import/export round-trip
|
||||
- AVAILABLE_MODES includes all 4 modes
|
||||
@@ -80,9 +80,9 @@ class TestThemeStructure:
|
||||
assert '_genSepia' in self.content
|
||||
|
||||
def test_theme_count(self):
|
||||
"""Should have 15 built-in themes."""
|
||||
"""Should have 16 built-in themes."""
|
||||
names = _extract_theme_names(self.content)
|
||||
assert len(names) == 15, f"Expected 15 themes, found {len(names)}: {names}"
|
||||
assert len(names) == 16, f"Expected 16 themes, found {len(names)}: {names}"
|
||||
|
||||
def test_required_export_functions(self):
|
||||
"""All required functions should be exported."""
|
||||
|
||||
Reference in New Issue
Block a user