Compare commits
191
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5154afed01 | ||
|
|
8c40aba850 | ||
|
|
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 | ||
|
|
cb80b08f77 | ||
|
|
e5577b9996 | ||
|
|
9bc31d069b | ||
|
|
411667fd88 | ||
|
|
ed9029d760 | ||
|
|
1892062d88 | ||
|
|
cbe3d9bc75 | ||
|
|
2ebfe74459 | ||
|
|
6257adb46b | ||
|
|
31a0ab5b70 | ||
|
|
f783394f09 | ||
|
|
2be0b15bcf | ||
|
|
de6bde1613 | ||
|
|
fdc9f47a0d |
@@ -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
|
||||
|
||||
+564
-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.66.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.81.4**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,569 @@ 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é
|
||||
|
||||
- **Fusion ACC-3.1 (#216) — émettrice SSO `draco_session`** : ObsiGate
|
||||
émet le cookie de session partagé de l'écosystème dracodev (JWS
|
||||
HS256, claims minimaux, 12 h, domaine `.dracodev.net`) à chaque
|
||||
connexion, le renouvelle de façon glissante (middleware dédié), le
|
||||
révoque à la déconnexion (liste des `jti` exposée aux autres apps
|
||||
sous clé machine) et offre `GET /sso/authorize` avec `return_to`
|
||||
en liste blanche anti open-redirect. Inerte tant que
|
||||
`DRACO_SESSION_SECRET` n'est pas configuré. Fiche :
|
||||
`docs/features/fusion-acc31-sso-draco-session-216.md`.
|
||||
|
||||
---
|
||||
|
||||
## [2.70.1] — 2026-10-20
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **Sécurité des dépendances (CI `pip-audit` rouge sur 2.70.0)** —
|
||||
planchers relevés dans `backend/requirements.txt`, selon le patron
|
||||
déjà en place dans le fichier : `aiohttp>=3.14.4` (8 CVE corrigées en
|
||||
3.14.4, dont CVE-2026-111063/111064), `mcp==1.30.0` (CVE-2026-59951),
|
||||
`pillow>=13.0.0` (8 CVE corrigées en 13.0.0, dont CVE-2026-112895) et
|
||||
plancher explicite `certifi>=2026.10.19` (PYSEC-2026-4320, dépendance
|
||||
transitive). `pip-audit` redevient vierge ; aucune ligne de code
|
||||
applicatif n'est touchée.
|
||||
|
||||
---
|
||||
|
||||
## [2.70.0] — 2026-10-20
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#215 — Fusion bloc 1 : barre d'apps commune et références croisées des
|
||||
notes** (programme Fusion, issues Gitea #5/#6/#7 ; contrats
|
||||
`dracodev-commons` v0.1.0) :
|
||||
- **Barre d'apps commune** en tête de l'interface (snippet figé vendangé :
|
||||
`frontend/draco-barre.css/.js`, markup dans `index.html`) — navigation
|
||||
vers ObsiGate, Flowdeck, TikTak et Kanbante, ObsiGate marquée active,
|
||||
sélecteur de domaine mémorisé ; la recherche, le rail et le menu délèguent
|
||||
aux contrôles natifs d'ObsiGate.
|
||||
- **Références croisées `external_ref`** : section « Liens » dans la vue
|
||||
d'une note — coller une URL profonde (Flowdeck, TikTak, issue Gitea =
|
||||
carte Kanbante, ObsiGate) crée la référence (libellé automatique ou
|
||||
libre, pastille de couleur de l'app cible). Backend : stockage JSON par
|
||||
utilisateur (`backend/external_refs.py`), endpoints `GET/POST /api/refs`
|
||||
et `DELETE /api/refs/{id}`, parseur d'URL conforme au contrat figé
|
||||
(422 explicite sinon), schémas et tag OpenAPI « ExternalRefs ».
|
||||
- **Notes projetées TikTak reconnues** (frontmatter `tiktak_id`) : bandeau
|
||||
« Tâche TikTak projetée » et bouton « Ouvrir dans TikTak » dans la même
|
||||
section (module `frontend/draco-liens.js`, point d'extension
|
||||
`obsigate:file-rendered` émis par `renderFile`).
|
||||
- i18n FR/EN (clés `draco.links.*`) ; tests `test_external_refs.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.69.0] — 2026-10-15
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#201 — menu contextuel « Déplacer » / « Dupliquer »** (fichiers *et* dossiers) dans la
|
||||
sidebar : le modal de destination réutilise `promptTargetFolder` (celui de l'import, même
|
||||
thème, sélecteur de voute + arbo filtrable), généralisé par un paramètre `opts` optionnel
|
||||
(titre, résumé, libellé du bouton) ; l'emplacement actuel est pré-sélectionné ; cross-vault
|
||||
refusé proprement. Nouveau endpoint `POST /api/duplicate/{vault}` (`duplicate_path` : copie
|
||||
`X copie.ext`, collisions `copie 2`, `copie 3`… ; répertoires récursifs ; refus de dupliquer
|
||||
un dossier dans lui-même ; index incrémental, SSE + webhooks). i18n FR/EN.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-117** — drag & drop interne de la sidebar (déplacement d'un fichier/dossier vers un
|
||||
autre) inopérant et silencieux depuis #89 : la détection « dépôt externe » dans `onDrop`
|
||||
testait `items.length > 0`, qui comptait aussi les formats *string* (`tree:*`) posés par
|
||||
`setData` au `dragstart` interne → chaque déplacement basculait dans la branche upload et
|
||||
disparaissait sans appel API ni toast. Restreinte aux vrais fichiers (`kind === 'file'`,
|
||||
type `Files`, `files` non vide), avec priorité au `dragState`.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## [2.68.6] — 2026-10-14
|
||||
|
||||
---
|
||||
|
||||
## [2.68.5] — 2026-10-14
|
||||
|
||||
---
|
||||
|
||||
## [2.68.4] — 2026-10-14
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-116** — éditeur Forge : après insertion d'un tableau (ou bloc de code,
|
||||
diagramme), la textarea restait d'une seule ligne — les mutations
|
||||
programmatiques de `ta.value` ne déclenchent pas l'événement `input`, seul
|
||||
chemin qui rappelait `autoHeight()`. `insertAtCursor` et `wrapSelection` (les
|
||||
deux points de passage de toutes les insertions) appellent désormais
|
||||
`autoHeight()` + `updateLineNumbers()` — le nombre de lignes du gutter suit.
|
||||
Vérifié en Playwright : tableau 4 lignes sur document d'une ligne → hauteur
|
||||
51 → 189 px, gutter cohérent.
|
||||
|
||||
- **BUG-115** — éditeur Forge (aperçu et menus) : trois défauts —
|
||||
(1) collure case à cocher/texte dans l'aperçu des checklists : le HTML du
|
||||
serveur ne sépare pas l'`<input>` du texte et `editor-poc.html` n'avait pas
|
||||
la règle `.task-list-item` de `style.css` (ajoutée à son `<style>`) ;
|
||||
(2) l'aperçu « flashait » : chaque frappe effaçait le rendu pour afficher
|
||||
« Rendering... » — l'ancien contenu reste désormais affiché pendant le
|
||||
rafraîchissement, et la dernière frappe est rejouée (flag `pending`) au lieu
|
||||
d'être perdue ; (3) le menu « dimensions du tableau » (et le sélecteur de
|
||||
langage) était posé en `position: absolute` avec des coordonnées viewport →
|
||||
double décalage → menu coupé hors du champ sur un document d'une ligne ;
|
||||
passés en `position: fixed` avec clamp viewport comme le menu slash, et
|
||||
`stopPropagation` sur les clics des menus (la bulle fermait la forme
|
||||
aussitôt ouverte). Vérifié en Playwright sur l'instance E2E : spacing 6 px,
|
||||
aucun spinner pendant la frappe, forme entièrement visible + insertion OK.
|
||||
|
||||
- **BUG-114** — popup « `currentPath is not defined` » après suppression (ou
|
||||
renommage) d'un répertoire : `frontend/js/ui.js` référençait `currentPath` nu
|
||||
(non déclaré — la variable vit dans `state`) au lieu de `state.currentPath`
|
||||
dans les branches répertoire de `confirmDeleteDirectory` et du renommage en
|
||||
ligne. La `ReferenceError` était avalée par le `catch` et affichée en toast
|
||||
d'erreur. Trois usages corrigés ; vérifié en Playwright (flux réel clic droit
|
||||
→ Supprimer) : toast « Dossier supprimé », aucun fantôme, 0 erreur JS.
|
||||
|
||||
---
|
||||
|
||||
## [2.68.3] — 2026-10-14
|
||||
|
||||
### Amélioré
|
||||
|
||||
- **BUG-113 — Suppression de répertoire rapide** : `DELETE /api/directory/{vault}`
|
||||
re-scannait la vault entière et reconstruisait l'index inversé global après le
|
||||
`rmtree` (`reload_single_vault`) — le temps croissait avec toute la vault, pas
|
||||
avec le dossier supprimé. Nouveau chemin incrémental
|
||||
(`remove_directory_from_index`) qui retire uniquement les fichiers du
|
||||
sous-arbre (index, paths, index inversé et sémantique, dossiers fantômes
|
||||
purgés). Mesuré sur l'instance E2E : 60 fichiers supprimés en **63 ms** au
|
||||
lieu de **668 ms** (~10×, écart qui grandit avec la taille du home-user).
|
||||
Tests : `test_delete_directory_subtree_clears_index` (aucun fantôme dans
|
||||
paths ni dans la recherche plein-texte après suppression).
|
||||
|
||||
---
|
||||
|
||||
## [2.68.2] — 2026-10-14
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **CI security — bandit bloquant sur B112** : `backend/services/search.py`
|
||||
(`search_shared_notes` : un partage illisible était ignoré via
|
||||
`except Exception: continue`) déclenchait `B112:try_except_continue`, le job
|
||||
`security` sortait en rouge. Le `continue` est **délibéré** (un partage
|
||||
inaccessible ne doit pas faire échouer la recherche) — `# nosec B112` posé
|
||||
sur la ligne, comme les `# nosec B110` existants (`watcher.py`, `B105`,
|
||||
`B324`). Bandit re-vérifié localement : 0 issue ; l'étape reste bloquante
|
||||
(garde-fou `test_bandit_and_pip_audit_stay_blocking` intact).
|
||||
|
||||
---
|
||||
|
||||
## [2.68.1] — 2026-10-14
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **UI configuration — boîtes d'édition « Clés API & MCP »** : la rangée de
|
||||
création de clé (nom / expiration / portée / vaults / bouton) débordait de la
|
||||
section sur desktop — `flex-wrap: wrap` n'était appliqué qu'en mobile
|
||||
(≤768px). Le `flex-wrap` est désormais global sur `.config-add-row`, les
|
||||
champs passent en `flex: 1 1 140px` (avec `width: auto !important` pour
|
||||
neutraliser les largeurs inline 180/140/150/200px) et le bouton en
|
||||
`flex: 1 1 auto` : les composants passent à la ligne au lieu de déborder.
|
||||
Vérifié aux viewports 1600 / 1100 / 900 px (overflow horizontal : 0).
|
||||
|
||||
---
|
||||
|
||||
## [2.68.0] — 2026-10-14
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#197 - Listes de fichiers : `mtime` + `size` pour réconciliation externe (Gitea #4)**
|
||||
- `GET /api/vault/{vault}/paths` expose `size`/`mtime`/`mtime_iso` par fichier
|
||||
(index, même contrat que `list_all_files`) ; `GET /api/browse/{vault}` expose
|
||||
`modified`/`modified_iso` ; outil MCP `list_directory` aligné.
|
||||
- `PUT /api/file/{vault}/save` rafraîchit l'index en synchrone (read-after-write).
|
||||
- Fiche `docs/features/tiktak-integration-197-200.md`, guide API_REST §5, tests
|
||||
`tests/test_sync_lists.py` (7).
|
||||
- **#198 - Webhooks : filtre `vault` / `path_prefix` + `file_modified` émis (Gitea #3)**
|
||||
- Filtres optionnels par webhook (sans filtre = tout, comme avant) ; `POST`/`PATCH`/
|
||||
`GET /api/webhooks` les gèrent ; `PUT …/save` émet `file_modified`.
|
||||
- UI : champs filtre + badge, aide intégrée FR/EN à jour ; tests
|
||||
`tests/test_webhook_filters.py` (10).
|
||||
- **#199 - Clés API à portée limitée : vaults + périmètre `files` (Gitea #2)**
|
||||
- `POST /api/auth/tokens` accepte `vaults?` + `scope` (`full`|`files`) ; 403 hors
|
||||
vault (REST + MCP) et hors périmètre (`TokenScopeMiddleware`, outils
|
||||
`token_scope_denied`, `list_tools` filtré) ; UI + i18n FR/EN ; clés existantes
|
||||
inchangées (`full`). Guides API_REST (§2.3) / MCP ; tests
|
||||
`tests/test_scoped_tokens.py` (13).
|
||||
- **#200 - Runbook de provisionnement du vault « TikTak » (Gitea #1)** — procédure
|
||||
d'exploitation + recette `curl` dans la fiche feature (reste à exécuter sur l'hôte).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-112** — `pwsh -File scripts/e2e-server.ps1 2>&1 | head -n 20` ne se
|
||||
terminait jamais : `Start-Process` laissait uvicorn hériter des handles
|
||||
stdout/stderr du pwsh parent, le pipe vers `head` restait ouvert tant que le
|
||||
serveur (immortel par design) vivait. Le serveur, lui, était prêt en ~7 s.
|
||||
`scripts/e2e-server.ps1` lance désormais uvicorn via `Win32_Process.Create`
|
||||
(WMI), processus détaché sans handle hérité ; les variables `OBSIGATE_*` sont
|
||||
passées dans la ligne `cmd /c` via `set "NOM=valeur"` (le processus WMI
|
||||
n'hérite pas de l'environnement du parent). Vérifié : commande en 6,9 s,
|
||||
serveur up avec les 2 fixtures, `stop`/`status` OK.
|
||||
|
||||
- **#197** — `update_single_file`/`remove_single_file` recevaient des chemins relatifs
|
||||
au vault alors qu'ils exigeaient un absolu : le rafraîchissement d'index
|
||||
post-écriture était un no-op silencieux (masqué par le watcher en production).
|
||||
Les deux formes sont désormais acceptées.
|
||||
|
||||
---
|
||||
|
||||
## [2.67.2] — 2026-10-10
|
||||
|
||||
---
|
||||
|
||||
## [2.67.1] — 2026-10-10
|
||||
|
||||
---
|
||||
|
||||
## [2.67.0] — 2026-10-10
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#196 — Partage dirigé (itération 2 : visibilité & recherche)**
|
||||
- Icône « partagé » (share-2 bleu) dans l'arborescence sur tout fichier
|
||||
porteur d'un partage actif (créé ou reçu).
|
||||
- Dossier virtuel **Partage** à la racine du dossier personnel du
|
||||
destinataire : documents reçus listés avec l'icône de partage et
|
||||
« Partagé par X » en infobulle ; ouverture en lecture seule via la page
|
||||
de partage.
|
||||
- Les documents reçus sont **cherchables** : la recherche plein texte
|
||||
couvre les partages dirigés reçus (résultats `home-<user>/Partage/…`,
|
||||
clic → page de partage, jamais l'éditeur du vault d'autrui).
|
||||
- Cache d'icônes partagé invalidé à la création/révocation.
|
||||
- **Itération 3 :** correction « Directory not found: Partage » (le dossier
|
||||
virtuel ne déclenche plus la navigation) ; un fichier reçu s'ouvre dans
|
||||
l'application en onglet — arbre, recherche et dashboard — via résolution
|
||||
serveur `home-<user>/Partage/<fichier>` vers le fichier source
|
||||
(`/api/file`, raw, download) ; un `path` vide/null est rejeté (400) à la
|
||||
création d'un partage.
|
||||
- **Itération 4 :** correctifs d'ouverture — « Accès refusé à la vault … »
|
||||
(le partage dirigé est l'autorisation, l'ACL du vault source est
|
||||
court-circuitée après résolution `Partage/…`), « File not found:
|
||||
Partage/… » (les partages non adressés à l'utilisateur ne s'affichent
|
||||
plus comme reçus ; chemin canonique `Partage/<token>/<nom>` pour
|
||||
désambiguïser les fichiers homonymes).
|
||||
|
||||
---
|
||||
|
||||
## [2.66.0] — 2026-10-10
|
||||
|
||||
### 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.66.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.66.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.66.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.66.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.
|
||||
|
||||
|
||||
@@ -215,15 +215,37 @@ def _save_api_tokens(data: dict):
|
||||
tmp.replace(API_TOKENS_FILE)
|
||||
|
||||
|
||||
def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]:
|
||||
#: Endpoint perimeters accepted for scoped API tokens (#199). ``"full"``
|
||||
#: keeps today's behaviour ; ``"files"`` restricts the token to the file
|
||||
#: read/write surface (enforced by ``TokenScopeMiddleware`` on REST and by
|
||||
#: the tool registry on MCP/agent calls).
|
||||
API_TOKEN_SCOPES = ("full", "files")
|
||||
|
||||
|
||||
def create_api_token(
|
||||
user: dict,
|
||||
name: str,
|
||||
expiry_key: str,
|
||||
vaults: list[str] | None = None,
|
||||
scope: str = "full",
|
||||
) -> tuple[dict, str]:
|
||||
"""Create a persistent API/MCP token. Returns (record, jwt_string).
|
||||
|
||||
``expiry_key`` must be one of API_TOKEN_EXPIRY_CHOICES; "never" omits the
|
||||
``exp`` claim (valid until explicitly revoked).
|
||||
|
||||
#199 (synchro TikTak) — optional least-privilege restriction:
|
||||
``vaults`` limits the token to the given vaults (``None`` = snapshot of
|
||||
the creator's vaults, i.e. today's behaviour) and ``scope`` limits the
|
||||
endpoint perimeter (``"full"`` or ``"files"``). Both are embedded in the
|
||||
JWT so enforcement stays stateless; the record keeps them for display.
|
||||
"""
|
||||
if expiry_key not in API_TOKEN_EXPIRY_CHOICES:
|
||||
raise ValueError("Expiration invalide")
|
||||
if scope not in API_TOKEN_SCOPES:
|
||||
raise ValueError("Portée invalide (full | files)")
|
||||
seconds = API_TOKEN_EXPIRY_CHOICES[expiry_key]
|
||||
resolved_vaults = list(vaults) if vaults is not None else list(user.get("vaults", []))
|
||||
with _api_tokens_lock:
|
||||
data = _load_api_tokens()
|
||||
tokens = data["tokens"]
|
||||
@@ -235,11 +257,12 @@ def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]
|
||||
payload = {
|
||||
"sub": user["username"],
|
||||
"role": user.get("role", "user"),
|
||||
"vaults": user.get("vaults", []),
|
||||
"vaults": resolved_vaults,
|
||||
"jti": jti,
|
||||
"iat": now,
|
||||
"type": "access",
|
||||
"api": True,
|
||||
"scope": scope,
|
||||
}
|
||||
if seconds is not None:
|
||||
payload["exp"] = now + seconds
|
||||
@@ -252,6 +275,8 @@ def create_api_token(user: dict, name: str, expiry_key: str) -> tuple[dict, str]
|
||||
"expires_at": payload.get("exp"),
|
||||
"expiry_key": expiry_key,
|
||||
"last_used_at": None,
|
||||
"vaults": resolved_vaults,
|
||||
"scope": scope,
|
||||
}
|
||||
tokens[jti] = record
|
||||
_save_api_tokens(data)
|
||||
@@ -262,11 +287,16 @@ def list_api_tokens(username: str) -> list[dict]:
|
||||
"""Token metadata for one user, newest first."""
|
||||
data = _load_api_tokens()
|
||||
now = int(time.time())
|
||||
items = [
|
||||
{**t, "expired": t.get("expires_at") is not None and t["expires_at"] < now}
|
||||
for t in data["tokens"].values()
|
||||
if t["username"] == username
|
||||
]
|
||||
items = []
|
||||
for t in data["tokens"].values():
|
||||
if t["username"] != username:
|
||||
continue
|
||||
# #199 : les clés créées avant la portée limitée n'ont ni scope ni
|
||||
# vaults stockés → portée pleine, comportement inchangé.
|
||||
entry = {**t, "expired": t.get("expires_at") is not None and t["expires_at"] < now}
|
||||
entry.setdefault("scope", "full")
|
||||
entry.setdefault("vaults", None)
|
||||
items.append(entry)
|
||||
return sorted(items, key=lambda t: t["created_at"], reverse=True)
|
||||
|
||||
|
||||
|
||||
@@ -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,14 +110,17 @@ 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", [])
|
||||
# Attach the token id for per-token rate limiting (AI tool layer).
|
||||
user["_token_jti"] = payload.get("jti")
|
||||
# #199 : périmètre d'endpoints du token (défaut "full" = comportement
|
||||
# actuel, y compris pour les clés créées avant la portée limitée).
|
||||
user["_token_scope"] = payload.get("scope", "full")
|
||||
# Feature #107: track last usage of user-managed API/MCP tokens
|
||||
# (throttled write — this dependency runs on both REST and /mcp paths).
|
||||
if payload.get("api"):
|
||||
@@ -124,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:
|
||||
|
||||
+50
-4
@@ -7,10 +7,12 @@ 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
|
||||
|
||||
from backend import draco_session
|
||||
from backend.ratelimit import is_account_rate_limited, is_rate_limited
|
||||
from backend.ratelimit import record_account_failure as rl_record_account_failure
|
||||
from backend.ratelimit import record_account_success as rl_record_account_success
|
||||
@@ -280,6 +282,11 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
||||
secure=secure,
|
||||
path="/",
|
||||
)
|
||||
# Fusion ACC-3.1 (#216) : ObsiGate est l'émettrice SSO — chaque
|
||||
# émission de jetons natifs s'accompagne du cookie partagé
|
||||
# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas
|
||||
# configuré).
|
||||
draco_session.set_cookie(response, user, secure)
|
||||
return {
|
||||
"access_token": access_token,
|
||||
# OAuth2 token_type, pas un mot de passe (B105) :
|
||||
@@ -396,6 +403,19 @@ async def logout(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Fusion ACC-3.1 (#216) : déconnexion globale — révoquer le jti du
|
||||
# cookie partagé et effacer le cookie sur le domaine parent.
|
||||
draco_tok = request.cookies.get(draco_session.COOKIE_NAME)
|
||||
if draco_tok:
|
||||
draco_claims = draco_session.decode_claims(draco_tok)
|
||||
if draco_claims and draco_claims.get("jti"):
|
||||
try:
|
||||
draco_session.revoke(str(draco_claims["jti"]),
|
||||
int(draco_claims.get("exp", 0)))
|
||||
except Exception:
|
||||
pass
|
||||
draco_session.clear_cookie(response)
|
||||
|
||||
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
||||
response.delete_cookie("access_token", path="/")
|
||||
response.delete_cookie("access_token", path="/api") # just in case
|
||||
@@ -677,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
|
||||
@@ -697,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)
|
||||
@@ -1003,6 +1023,8 @@ async def delete_user_endpoint(
|
||||
class CreateApiTokenRequest(BaseModel):
|
||||
name: str
|
||||
expiry: str # 1d | 30d | 180d | 365d | never
|
||||
vaults: list[str] | None = None # #199 : None = vaults du créateur (comportement actuel)
|
||||
scope: str = "full" # #199 : "full" | "files"
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
@@ -1020,9 +1042,33 @@ async def create_user_token(
|
||||
request: Request,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Create a long-lived API/MCP token. The raw JWT is returned ONCE."""
|
||||
"""Create a long-lived API/MCP token. The raw JWT is returned ONCE.
|
||||
|
||||
#199 : portée optionnelle — ``vaults`` (sous-ensemble des vaults du
|
||||
créateur, défaut = tous) et ``scope`` (``"full"`` ou ``"files"``).
|
||||
"""
|
||||
from backend.auth.jwt_handler import API_TOKEN_SCOPES
|
||||
from backend.auth.middleware import check_vault_access
|
||||
from backend.indexer import index
|
||||
|
||||
scope = (req.scope or "full").strip().lower()
|
||||
if scope not in API_TOKEN_SCOPES:
|
||||
raise HTTPException(400, "Portée invalide (full | files)")
|
||||
vaults: list[str] | None = None
|
||||
if req.vaults is not None:
|
||||
cleaned = sorted({v.strip() for v in req.vaults if isinstance(v, str) and v.strip()})
|
||||
if not cleaned:
|
||||
raise HTTPException(400, "Liste de vaults vide")
|
||||
unknown = [v for v in cleaned if v not in index]
|
||||
if unknown:
|
||||
raise HTTPException(400, f"Vaults inconnus : {', '.join(unknown)}")
|
||||
denied = [v for v in cleaned if not check_vault_access(v, current_user)]
|
||||
if denied:
|
||||
raise HTTPException(403, f"Accès refusé aux vaults : {', '.join(denied)}")
|
||||
vaults = cleaned
|
||||
try:
|
||||
record, token = create_api_token(current_user, req.name.strip(), req.expiry)
|
||||
record, token = create_api_token(current_user, req.name.strip(), req.expiry,
|
||||
vaults=vaults, scope=scope)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
from backend.audit import log_config_change
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# backend/auth/scope.py
|
||||
# Scoped API/MCP tokens (#199, synchro TikTak — Gitea #2).
|
||||
#
|
||||
# A token may carry an endpoint perimeter in its ``scope`` JWT claim:
|
||||
# - ``"full"`` (default, and the case of every token issued before #199):
|
||||
# no restriction — today's behaviour, unchanged ;
|
||||
# - ``"files"``: file read/write surface only (vault file CRUD, listings,
|
||||
# search, backups read/restore). Everything else (admin, config, AI,
|
||||
# webhooks, shares, auth management…) answers **403**.
|
||||
#
|
||||
# Vault restriction is orthogonal and needs no new machinery: a scoped token
|
||||
# embeds the allowed vault list in its ``vaults`` JWT claim, so the existing
|
||||
# ``check_vault_access`` (REST routes) and ``ToolContext.require_vault_access``
|
||||
# (MCP/agent tools) refuse other vaults with 403 — for REST *and* MCP alike.
|
||||
"""Endpoint perimeter for scoped API tokens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
SCOPE_FULL = "full"
|
||||
SCOPE_FILES = "files"
|
||||
KNOWN_SCOPES = (SCOPE_FULL, SCOPE_FILES)
|
||||
|
||||
# (methods, path prefix or exact path). ``methods`` None = any method.
|
||||
# Keep this list tight: a sync client (TikTak) needs file CRUD, listings,
|
||||
# search and backup read/restore — nothing else.
|
||||
_RULES_FOR_FILES: tuple[tuple[frozenset[str] | None, str, bool], ...] = (
|
||||
# (methods, path, exact_only)
|
||||
(frozenset({"GET"}), "/api/health", False),
|
||||
(frozenset({"GET"}), "/api/auth/me", True),
|
||||
(frozenset({"GET"}), "/api/vaults", True),
|
||||
(None, "/api/browse/", False),
|
||||
(None, "/api/file/", False),
|
||||
(None, "/api/media/", False),
|
||||
(None, "/api/directory/", False),
|
||||
(None, "/api/move/", False),
|
||||
(None, "/api/vault/", False), # {vault}/files, {vault}/paths, batch-upload…
|
||||
(frozenset({"GET"}), "/api/search", False),
|
||||
(frozenset({"GET"}), "/api/tree-search", True),
|
||||
(frozenset({"GET"}), "/api/suggest", False),
|
||||
(frozenset({"GET"}), "/api/tags", False),
|
||||
(frozenset({"GET"}), "/api/graph/", False),
|
||||
(frozenset({"GET"}), "/api/backups", False),
|
||||
(frozenset({"GET"}), "/docs", False),
|
||||
(frozenset({"GET"}), "/redoc", True),
|
||||
(frozenset({"GET"}), "/openapi.json", True),
|
||||
(None, "/mcp", False),
|
||||
)
|
||||
|
||||
|
||||
def is_path_allowed_for_scope(scope: str | None, method: str, path: str) -> bool:
|
||||
"""True when a token with *scope* may call (*method*, *path*).
|
||||
|
||||
- ``None``/``"full"`` → everything allowed (backward compatible).
|
||||
- ``"files"`` → allow-list above (API surface only ; static assets and
|
||||
public pages carry no data and are handled by the caller — the
|
||||
middleware only gates ``/api/*`` and ``/mcp*``).
|
||||
- unknown scope → fail-closed (deny).
|
||||
"""
|
||||
if scope in (None, SCOPE_FULL):
|
||||
return True
|
||||
if scope != SCOPE_FILES:
|
||||
return False
|
||||
if not (path.startswith("/api/") or path.startswith("/mcp")):
|
||||
return True
|
||||
method = (method or "GET").upper()
|
||||
for methods, rule_path, exact in _RULES_FOR_FILES:
|
||||
if methods is not None and method not in methods:
|
||||
continue
|
||||
if exact:
|
||||
if path == rule_path:
|
||||
return True
|
||||
elif path == rule_path or path.startswith(rule_path):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def scope_of_user(user: dict[str, Any] | None) -> str:
|
||||
"""Endpoint scope attached to an authenticated user (default ``"full"``)."""
|
||||
if not user:
|
||||
return SCOPE_FULL
|
||||
return user.get("_token_scope") or SCOPE_FULL
|
||||
|
||||
|
||||
class TokenScopeMiddleware:
|
||||
"""Pure-ASGI middleware enforcing the endpoint perimeter of scoped tokens.
|
||||
|
||||
Denies out-of-perimeter calls made with a scoped token (**403**) before
|
||||
they reach the routes. Only *adds* restrictions: the ``scope`` claim is
|
||||
read unverified here, and the signature is still verified downstream by
|
||||
``get_current_user`` (an invalid token is rejected with 401 a step later
|
||||
anyway). Full-scope and scopeless tokens pass through untouched.
|
||||
"""
|
||||
|
||||
def __init__(self, app: Any):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
||||
if scope.get("type") == "http":
|
||||
method = scope.get("method", "GET")
|
||||
path = scope.get("path", "")
|
||||
if path.startswith("/api/") or path.startswith("/mcp"):
|
||||
token_scope = _scope_from_asgi(scope)
|
||||
if token_scope not in (None, SCOPE_FULL) and not is_path_allowed_for_scope(
|
||||
token_scope, method, path
|
||||
):
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
response = JSONResponse(
|
||||
{"detail": "Token à portée limitée : endpoint hors périmètre 'files'"},
|
||||
status_code=403,
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
def _scope_from_asgi(asgi_scope: Any) -> str | None:
|
||||
"""Extract the unverified ``scope`` claim from an ASGI scope's auth."""
|
||||
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in asgi_scope.get("headers", [])}
|
||||
token: str | None = None
|
||||
auth = headers.get("authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth[7:].strip()
|
||||
else:
|
||||
for part in headers.get("cookie", "").split(";"):
|
||||
name, _, value = part.strip().partition("=")
|
||||
if name.strip() == "access_token" and value.strip():
|
||||
token = value.strip()
|
||||
break
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
from jose import jwt as _jwt
|
||||
|
||||
claims = _jwt.get_unverified_claims(token)
|
||||
except Exception:
|
||||
return None
|
||||
token_scope = (claims or {}).get("scope")
|
||||
return token_scope if isinstance(token_scope, str) and token_scope else None
|
||||
@@ -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}
|
||||
@@ -0,0 +1,307 @@
|
||||
# backend/draco_session.py
|
||||
# Fusion ACC-3.1 (#216) — Émettrice SSO « draco_session » (option 2a, C2).
|
||||
#
|
||||
# ObsiGate est l'unique émettrice du cookie de session partagé
|
||||
# ``draco_session`` (Domain=.dracodev.net) que les autres applications
|
||||
# de l'écosystème (Flowdeck, TikTak, Kanbante) valideront localement
|
||||
# (ACC-3.2, leurs propres tickets). Design : architecture Fusion §5.2 —
|
||||
# JWS compact HS256, claims minimaux (sub, name, iat, exp 12 h, jti),
|
||||
# aucun rôle dans le jeton, renouvellement glissant par l'émettrice,
|
||||
# révocation par jti exposée aux autres apps.
|
||||
#
|
||||
# Configuration (environnement) :
|
||||
# DRACO_SESSION_SECRET secret de signature partagé (obligatoire
|
||||
# pour activer la fonction ; sans lui,
|
||||
# aucun cookie n'est émis et rien ne change)
|
||||
# DRACO_SESSION_SECRET_PREV ancien secret accepté en vérification
|
||||
# pendant la fenêtre de rotation (24 h)
|
||||
# DRACO_SESSION_COOKIE_DOMAIN domaine du cookie (défaut .dracodev.net ;
|
||||
# vide = cookie lié à l'hôte, utile en dev)
|
||||
# DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la
|
||||
# liste de révocation (GET /api/sso/revocations)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
logger = logging.getLogger("obsigate.draco_session")
|
||||
|
||||
COOKIE_NAME = "draco_session"
|
||||
ALGORITHM = "HS256"
|
||||
TOKEN_TTL_SECONDS = 12 * 3600 # durée de vie du jeton (design §5.2)
|
||||
RENEWAL_WINDOW_SECONDS = 2 * 3600 # réémettre si exp - now < 2 h
|
||||
REVOKED_FILE = Path("data/draco_revoked.json")
|
||||
ALLOWED_RETURN_SUFFIX = "dracodev.net"
|
||||
|
||||
_lock = threading.Lock()
|
||||
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
|
||||
|
||||
|
||||
def _secret() -> str:
|
||||
return os.environ.get("DRACO_SESSION_SECRET", "")
|
||||
|
||||
|
||||
def _secret_prev() -> str:
|
||||
return os.environ.get("DRACO_SESSION_SECRET_PREV", "")
|
||||
|
||||
|
||||
def _cookie_domain() -> str:
|
||||
return os.environ.get("DRACO_SESSION_COOKIE_DOMAIN", ".dracodev.net")
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
"""La fonction est active seulement si le secret partagé est configuré."""
|
||||
return bool(_secret())
|
||||
|
||||
|
||||
# ── Révocation (jti) ────────────────────────────────────────────────────
|
||||
|
||||
def _load_revoked() -> dict[str, int]:
|
||||
global _revoked
|
||||
if _revoked is None:
|
||||
try:
|
||||
data = json.loads(REVOKED_FILE.read_text(encoding="utf-8"))
|
||||
_revoked = {str(k): int(v) for k, v in data.items()} \
|
||||
if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
_revoked = {}
|
||||
return _revoked
|
||||
|
||||
|
||||
def _save_revoked() -> None:
|
||||
REVOKED_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = REVOKED_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(_revoked or {}), encoding="utf-8")
|
||||
tmp.replace(REVOKED_FILE)
|
||||
|
||||
|
||||
def revoke(jti: str, exp: int) -> None:
|
||||
"""Révoque un jeton par son jti (déconnexion globale)."""
|
||||
with _lock:
|
||||
store = _load_revoked()
|
||||
store[jti] = int(exp)
|
||||
now = int(time.time())
|
||||
for k in [k for k, v in store.items() if v <= now]:
|
||||
del store[k] # purge des expirés
|
||||
_save_revoked()
|
||||
|
||||
|
||||
def is_revoked(jti: str) -> bool:
|
||||
with _lock:
|
||||
return jti in _load_revoked()
|
||||
|
||||
|
||||
def revoked_list() -> list[dict]:
|
||||
"""Liste des révocations actives (pour les autres apps, cache 60 s)."""
|
||||
with _lock:
|
||||
now = int(time.time())
|
||||
return [{"jti": jti, "exp": exp}
|
||||
for jti, exp in _load_revoked().items() if exp > now]
|
||||
|
||||
|
||||
# ── Émission / vérification ─────────────────────────────────────────────
|
||||
|
||||
def issue_token(user: dict) -> tuple[str, str, int]:
|
||||
"""Émet un JWS pour l'utilisateur. Retourne (jeton, jti, exp)."""
|
||||
now = int(time.time())
|
||||
exp = now + TOKEN_TTL_SECONDS
|
||||
jti = uuid.uuid4().hex
|
||||
claims = {
|
||||
"sub": str(user.get("id") or user.get("username") or ""),
|
||||
"name": str(user.get("display_name") or user.get("username") or ""),
|
||||
"iat": now,
|
||||
"exp": exp,
|
||||
"jti": jti,
|
||||
}
|
||||
return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp
|
||||
|
||||
|
||||
def decode_claims(token: str) -> dict | None:
|
||||
"""Vérifie signature et expiration (pas la révocation)."""
|
||||
if not token or not enabled():
|
||||
return None
|
||||
for secret in (_secret(), _secret_prev()):
|
||||
if not secret:
|
||||
continue
|
||||
try:
|
||||
return jwt.decode(token, secret, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def verify_token(token: str) -> dict | None:
|
||||
"""decode_claims + contrôle de révocation du jti."""
|
||||
claims = decode_claims(token)
|
||||
if claims is None:
|
||||
return None
|
||||
if claims.get("jti") and is_revoked(str(claims["jti"])):
|
||||
return None
|
||||
return claims
|
||||
|
||||
|
||||
def needs_renewal(claims: dict) -> bool:
|
||||
"""Vrai si le jeton entre dans la fenêtre de renouvellement (< 2 h)."""
|
||||
try:
|
||||
return int(claims["exp"]) - int(time.time()) < RENEWAL_WINDOW_SECONDS
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return True
|
||||
|
||||
|
||||
def set_cookie(response, user: dict, secure: bool) -> None:
|
||||
"""Pose le cookie draco_session sur une réponse (émission initiale)."""
|
||||
if not enabled():
|
||||
return
|
||||
token, _jti, _exp = issue_token(user)
|
||||
domain = _cookie_domain()
|
||||
extra = {"domain": domain} if domain else {}
|
||||
response.set_cookie(COOKIE_NAME, token, max_age=TOKEN_TTL_SECONDS,
|
||||
httponly=True, samesite="lax", secure=secure,
|
||||
path="/", **extra)
|
||||
|
||||
|
||||
def clear_cookie(response) -> None:
|
||||
domain = _cookie_domain()
|
||||
extra = {"domain": domain} if domain else {}
|
||||
response.delete_cookie(COOKIE_NAME, path="/", **extra)
|
||||
|
||||
|
||||
# ── Validation locale (ACC-3.2, #9) ─────────────────────────────────────
|
||||
|
||||
def user_for_draco(request) -> dict | None:
|
||||
"""Utilisateur ObsiGate prouvé par le cookie draco_session.
|
||||
|
||||
ObsiGate est à la fois émettrice et consommatrice : le `sub` du
|
||||
jeton EST son identifiant d'utilisateur — la correspondance est
|
||||
directe, par id. Le garde-fou « jeton antérieur au dernier
|
||||
changement de mot de passe » (BUG-028 côté natif) est appliqué
|
||||
à l'identique via le claim `iat`.
|
||||
"""
|
||||
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
|
||||
if not claims:
|
||||
return None
|
||||
sub = str(claims.get("sub", ""))
|
||||
if not sub:
|
||||
return None
|
||||
from backend.auth.user_store import get_all_users
|
||||
|
||||
for candidate in get_all_users():
|
||||
if str(candidate.get("id")) != sub:
|
||||
continue
|
||||
if not candidate.get("active"):
|
||||
return None
|
||||
pca = candidate.get("password_changed_at")
|
||||
iat = claims.get("iat")
|
||||
if pca is not None and iat is not None:
|
||||
try:
|
||||
if int(iat) < int(float(pca)):
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
user = dict(candidate)
|
||||
user["_token_vaults"] = candidate.get("vaults", [])
|
||||
user["_token_scope"] = "full"
|
||||
user["_draco_sub"] = sub
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
# ── Renouvellement glissant (middleware) ────────────────────────────────
|
||||
|
||||
class DracoRenewalMiddleware:
|
||||
"""Réémet le cookie quand l'utilisateur est actif chez l'émettrice.
|
||||
|
||||
Middleware ASGI pur (pas de BaseHTTPMiddleware : la pile d'ObsiGate
|
||||
streame des réponses SSE/WS que BaseHTTPMiddleware mettrait en
|
||||
tampon). On enveloppe l'envoi de la réponse : si la requête porte
|
||||
un cookie d'accès natif valide et que le cookie draco est absent,
|
||||
invalide ou dans sa fenêtre de renouvellement (< 2 h), on ajoute
|
||||
un Set-Cookie frais aux en-têtes de la réponse. La révocation
|
||||
ciblée d'un jti draco est ainsi « guérie » par la prochaine
|
||||
activité authentifiée locale — la déconnexion globale, elle,
|
||||
révoque aussi les jetons natifs, donc plus rien n'est réémis.
|
||||
"""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope.get("type") != "http" or not enabled():
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
cookie_header = ""
|
||||
for name, value in scope.get("headers") or []:
|
||||
if name == b"cookie":
|
||||
cookie_header = value.decode("latin-1")
|
||||
break
|
||||
cookies = {}
|
||||
for part in cookie_header.split(";"):
|
||||
if "=" in part:
|
||||
k, v = part.strip().split("=", 1)
|
||||
cookies[k] = v
|
||||
user = self._user_from_native(cookies.get("access_token", ""))
|
||||
if user is None:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
claims = verify_token(cookies.get(COOKIE_NAME, "")) \
|
||||
if cookies.get(COOKIE_NAME) else None
|
||||
if claims is not None and not needs_renewal(claims):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
token, _jti, _exp = issue_token(user)
|
||||
domain = _cookie_domain()
|
||||
cookie = (f"{COOKIE_NAME}={token}; Max-Age={TOKEN_TTL_SECONDS}; "
|
||||
f"Path=/; HttpOnly; SameSite=lax")
|
||||
if domain:
|
||||
cookie += f"; Domain={domain}"
|
||||
if scope.get("scheme") == "https":
|
||||
cookie += "; Secure"
|
||||
|
||||
async def send_with_cookie(message):
|
||||
if message.get("type") == "http.response.start":
|
||||
headers = list(message.get("headers") or [])
|
||||
headers.append((b"set-cookie", cookie.encode("latin-1")))
|
||||
message = {**message, "headers": headers}
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_cookie)
|
||||
|
||||
@staticmethod
|
||||
def _user_from_native(access_token: str):
|
||||
"""Utilisateur local prouvé par le cookie d'accès natif, ou None."""
|
||||
if not access_token:
|
||||
return None
|
||||
try:
|
||||
from backend.auth.jwt_handler import decode_token
|
||||
from backend.auth.user_store import get_user
|
||||
payload = decode_token(access_token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
return None
|
||||
return get_user(str(payload.get("sub", "")))
|
||||
except Exception: # le renouvellement ne casse jamais une requête
|
||||
return None
|
||||
|
||||
|
||||
# ── return_to (anti open-redirect) ──────────────────────────────────────
|
||||
|
||||
def validate_return_to(url: str) -> str | None:
|
||||
"""Accepte uniquement https vers dracodev.net ou un sous-domaine."""
|
||||
if not url:
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
host = (parsed.hostname or "").lower()
|
||||
if parsed.scheme != "https" or not host:
|
||||
return None
|
||||
if host == ALLOWED_RETURN_SUFFIX or host.endswith("." + ALLOWED_RETURN_SUFFIX):
|
||||
return url
|
||||
return None
|
||||
+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"/>
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Références croisées ``external_ref`` des notes — Fusion #215 (ACC-1.3).
|
||||
|
||||
Modèle figé par le programme Fusion dans ``dracodev-commons``
|
||||
(``contrats/external_ref.schema.json``, v0.1.0) : la référence vit chez
|
||||
le propriétaire de l'objet source — ici la note ObsiGate, identifiée
|
||||
par ``(vault, path)`` — et pointe vers un objet d'une autre application
|
||||
(projet ou page Flowdeck, tâche ou liste TikTak, carte Kanbante, note
|
||||
ObsiGate) par son URL profonde (``contrats/liens-profonds.md``). Une
|
||||
seule source de vérité par objet : on stocke des références, jamais
|
||||
des copies du contenu cible.
|
||||
|
||||
Stockage : JSON par utilisateur dans ``data/external_refs/``, même
|
||||
patron que :mod:`backend.history` (pas de base de données, cf.
|
||||
``AGENTS.md``). Le répertoire est surchargeable par la variable
|
||||
d'environnement ``OBSIGATE_EXTERNAL_REFS_DIR`` (tests).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, quote, unquote, urlparse
|
||||
|
||||
logger = logging.getLogger("obsigate.external_refs")
|
||||
|
||||
|
||||
def _refs_dir() -> Path:
|
||||
override = os.environ.get("OBSIGATE_EXTERNAL_REFS_DIR")
|
||||
directory = Path(override) if override else Path("data/external_refs")
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
|
||||
def _refs_file(username: str) -> Path:
|
||||
return _refs_dir() / f"{username}_refs.json"
|
||||
|
||||
|
||||
def _read_refs(username: str) -> list[dict[str, Any]]:
|
||||
file = _refs_file(username)
|
||||
if not file.exists():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(file.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read external refs from {file.name}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _write_refs(username: str, refs: list[dict[str, Any]]) -> None:
|
||||
file = _refs_file(username)
|
||||
try:
|
||||
tmp = file.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(refs, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
shutil.move(str(tmp), str(file))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to write external refs to {file.name}: {e}")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ parseur d'URL
|
||||
|
||||
def 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()
|
||||
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 app == "obsigate":
|
||||
frag = u.fragment or ""
|
||||
if frag.startswith("file="):
|
||||
cible = unquote(frag[5:])
|
||||
if ":" in cible:
|
||||
nom = cible.rsplit("/", 1)[-1]
|
||||
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
|
||||
return None
|
||||
if 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)}")
|
||||
m = re.fullmatch(r"/collections/(\d+)", u.path or "")
|
||||
if m:
|
||||
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
|
||||
return None
|
||||
if app == "gitea":
|
||||
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
|
||||
if m:
|
||||
owner, repo, num = m.groups()
|
||||
return ("kanbante", "card", f"{owner}/{repo}#{num}", f"Kanbante · {repo} #{num}")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------ CRUD
|
||||
|
||||
def list_refs(username: str, vault: str, path: str) -> list[dict[str, Any]]:
|
||||
"""Références posées sur une note, dans l'ordre de création.
|
||||
|
||||
``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 [
|
||||
_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`` (URL reconstruite, #223)."""
|
||||
for r in _read_refs(username):
|
||||
if r.get("id") == ref_id:
|
||||
return _avec_url_affichee(r)
|
||||
return None
|
||||
|
||||
|
||||
def add_ref(
|
||||
username: str,
|
||||
vault: str,
|
||||
path: str,
|
||||
target_app: str,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
target_url: str,
|
||||
label: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Ajoute une référence à une note et la retourne (avec son ``id``)."""
|
||||
refs = _read_refs(username)
|
||||
ref = {
|
||||
"id": uuid.uuid4().hex,
|
||||
"owner_type": "obsigate.note",
|
||||
"owner_id": f"{vault}:{path}",
|
||||
"owner_vault": vault,
|
||||
"owner_path": path,
|
||||
"target_app": target_app,
|
||||
"target_type": target_type,
|
||||
"target_id": target_id,
|
||||
"target_url": target_url,
|
||||
"label": label,
|
||||
"domain": None,
|
||||
"status": "ok",
|
||||
"created_by": "user",
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
refs.append(ref)
|
||||
_write_refs(username, refs)
|
||||
return ref
|
||||
|
||||
|
||||
def delete_ref(username: str, ref_id: str) -> dict[str, Any] | None:
|
||||
"""Retire une référence ; retourne la référence retirée ou ``None``."""
|
||||
refs = _read_refs(username)
|
||||
kept = [r for r in refs if r.get("id") != ref_id]
|
||||
if len(kept) == len(refs):
|
||||
return None
|
||||
removed = next(r for r in refs if r.get("id") == ref_id)
|
||||
_write_refs(username, kept)
|
||||
return _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)
|
||||
|
||||
|
||||
|
||||
+61
-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] = []
|
||||
@@ -1229,6 +1229,13 @@ async def update_single_file(vault_name: str, abs_file_path: str) -> dict[str, A
|
||||
if not vault_path:
|
||||
return None
|
||||
|
||||
# #197 : les routes passent historiquement un chemin relatif au vault
|
||||
# (``result["path"]``) alors que la docstring exigeait un absolu — le
|
||||
# rafraîchissement était un no-op silencieux (couvert en prod par le
|
||||
# watcher, mais pas dans les tests ni en polling lent). On accepte les deux.
|
||||
if not Path(abs_file_path).is_absolute():
|
||||
abs_file_path = str(Path(vault_path) / abs_file_path)
|
||||
|
||||
# Get vault configuration for hidden files handling
|
||||
vault_cfg = vault_data.get("config") or vault_config.get(vault_name, {})
|
||||
|
||||
@@ -1272,6 +1279,10 @@ async def remove_single_file(vault_name: str, abs_file_path: str) -> dict[str, A
|
||||
if not vault_path:
|
||||
return None
|
||||
|
||||
# #197 : même tolérance relatif/absolu que ``update_single_file``.
|
||||
if not Path(abs_file_path).is_absolute():
|
||||
abs_file_path = str(Path(vault_path) / abs_file_path)
|
||||
|
||||
try:
|
||||
rel_path = str(Path(abs_file_path).relative_to(vault_path)).replace("\\", "/")
|
||||
except ValueError:
|
||||
@@ -1301,6 +1312,52 @@ async def handle_file_move(vault_name: str, src_abs: str, dest_abs: str) -> dict
|
||||
return await update_single_file(vault_name, dest_abs)
|
||||
|
||||
|
||||
async def remove_directory_from_index(vault_name: str, dir_rel_path: str) -> int:
|
||||
"""Remove a directory subtree from the index without a full vault re-scan.
|
||||
|
||||
Incremental equivalent of ``reload_single_vault`` for deletions : every
|
||||
indexed file under *dir_rel_path* is dropped via
|
||||
``_remove_file_from_structures`` (which notifies the inverted and semantic
|
||||
indexes through the change hook), and the subtree's directory entries are
|
||||
purged from ``path_index``. Returns the number of files removed.
|
||||
|
||||
Used by :func:`api_directory_delete` — previously a ``rmtree`` was followed
|
||||
by a full vault re-scan + global inverted-index rebuild, which is linear in
|
||||
the whole vault size on every single directory deletion.
|
||||
|
||||
Args:
|
||||
vault_name: Name of the vault.
|
||||
dir_rel_path: Directory path relative to the vault root.
|
||||
|
||||
Returns:
|
||||
Number of indexed files removed.
|
||||
"""
|
||||
vault_data = index.get(vault_name)
|
||||
if not vault_data:
|
||||
return 0
|
||||
|
||||
prefix = dir_rel_path.rstrip("/") + "/"
|
||||
targets = [f["path"] for f in vault_data.get("files", []) if f["path"].startswith(prefix)]
|
||||
|
||||
lock = _get_async_lock()
|
||||
async with lock:
|
||||
removed = 0
|
||||
for rel_path in targets:
|
||||
if _remove_file_from_structures(vault_name, rel_path):
|
||||
removed += 1
|
||||
# Purge the subtree's directory entries too, otherwise empty dirs stay
|
||||
# in path_index as ghosts (file entries were removed per-file above).
|
||||
if vault_name in path_index:
|
||||
path_index[vault_name] = [
|
||||
p for p in path_index[vault_name]
|
||||
if not (p["path"] == dir_rel_path or p["path"].startswith(prefix))
|
||||
]
|
||||
|
||||
if removed:
|
||||
logger.info(f"Directory removed from index: {vault_name}/{dir_rel_path} ({removed} files)")
|
||||
return removed
|
||||
|
||||
|
||||
async def remove_vault_from_index(vault_name: str):
|
||||
"""Remove an entire vault from the index.
|
||||
|
||||
|
||||
+142
-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'; "
|
||||
@@ -486,6 +502,19 @@ app.add_middleware(SSESafeGZipMiddleware, minimum_size=1000)
|
||||
# Security headers on all responses
|
||||
app.add_middleware(SecurityHeadersMiddleware)
|
||||
|
||||
# Scoped API tokens (#199) : les clés à portée "files" sont confinées à la
|
||||
# surface fichiers côté REST (403 hors périmètre). Placé avant CORS pour que
|
||||
# le refus s'applique aussi aux requêtes out-of-scope.
|
||||
from backend.auth.scope import TokenScopeMiddleware
|
||||
|
||||
app.add_middleware(TokenScopeMiddleware)
|
||||
|
||||
# Fusion ACC-3.1 (#216) : renouvellement glissant du cookie SSO
|
||||
# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas défini).
|
||||
from backend.draco_session import DracoRenewalMiddleware
|
||||
|
||||
app.add_middleware(DracoRenewalMiddleware)
|
||||
|
||||
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
|
||||
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
|
||||
# donc toute lecture cross-origin est refusée (défense explicite, plus
|
||||
@@ -502,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
|
||||
@@ -511,10 +549,12 @@ 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
|
||||
from backend.routers.duplicates import router as duplicates_router
|
||||
from backend.routers.external_refs import router as external_refs_router
|
||||
from backend.routers.file_chat import router as file_chat_router
|
||||
from backend.routers.files_media import router as files_media_router
|
||||
from backend.routers.files_read import router as files_read_router
|
||||
@@ -526,6 +566,7 @@ from backend.routers.realtime import router as realtime_router
|
||||
from backend.routers.scheduler import router as scheduler_router
|
||||
from backend.routers.search import router as search_router
|
||||
from backend.routers.sharing import router as sharing_router
|
||||
from backend.routers.sso import router as sso_router
|
||||
from backend.routers.vaults import router as vaults_router
|
||||
from backend.routers.webhooks import router as webhooks_router
|
||||
from backend.skills_routes import router as skills_router
|
||||
@@ -536,11 +577,14 @@ app.include_router(bookslm_router)
|
||||
app.include_router(skills_router)
|
||||
app.include_router(health_router) # ROADMAP #85 T1 — System / health
|
||||
app.include_router(history_router) # ROADMAP #85 T8 — History
|
||||
app.include_router(external_refs_router) # Fusion #215 — Références croisées (ACC-1.3)
|
||||
app.include_router(sso_router) # Fusion #216 — Émettrice SSO draco_session (ACC-3.1)
|
||||
app.include_router(realtime_router) # ROADMAP #85 T9 — SSE + collab WS
|
||||
app.include_router(search_router) # ROADMAP #85 T5 — Search
|
||||
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
||||
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
|
||||
@@ -812,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")
|
||||
@@ -885,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")
|
||||
|
||||
@@ -43,6 +43,7 @@ from backend.auth.middleware import get_current_user, is_auth_enabled
|
||||
from backend.services.files import read_file_text
|
||||
from backend.services.vaults import list_accessible_vaults
|
||||
from backend.tools.api import (
|
||||
FILES_SCOPE_TOOLS,
|
||||
ToolContext,
|
||||
ToolError,
|
||||
ToolMode,
|
||||
@@ -191,6 +192,14 @@ def _tool_definitions() -> list[types.Tool]:
|
||||
return definitions
|
||||
|
||||
|
||||
def _mcp_base_name(mcp_name: str) -> str:
|
||||
"""Strip the ``propose_``/``apply_`` prefix back to the registry tool name."""
|
||||
for prefix in ("propose_", "apply_"):
|
||||
if mcp_name.startswith(prefix):
|
||||
return mcp_name[len(prefix):]
|
||||
return mcp_name
|
||||
|
||||
|
||||
def _parse_vault_uri(uri: str) -> tuple[str, str]:
|
||||
"""Split ``vault://name/path`` into ``(vault, path)``."""
|
||||
parsed = urlparse(uri)
|
||||
@@ -208,6 +217,10 @@ def build_server() -> Server:
|
||||
# ── Tools ──────────────────────────────────────────────────────────
|
||||
@server.list_tools()
|
||||
async def _list_tools() -> list[types.Tool]:
|
||||
# #199 : un token à portée "files" ne voit que les outils fichiers.
|
||||
user = _user_from_context(server)
|
||||
if (user.get("_token_scope") or "full") == "files":
|
||||
return [t for t in _tool_definitions() if _mcp_base_name(t.name) in FILES_SCOPE_TOOLS]
|
||||
return _tool_definitions()
|
||||
|
||||
@server.call_tool()
|
||||
@@ -262,6 +275,7 @@ def build_server() -> Server:
|
||||
user = _user_from_context(server)
|
||||
vault, path = _parse_vault_uri(str(uri))
|
||||
ctx = ToolContext(user=user, mode=ToolMode.MCP)
|
||||
ctx.require_tool_allowed("read_file") # #199 : portée inconnue → refus
|
||||
ctx.require_vault_access(vault)
|
||||
data = read_file_text(vault, path, redact=True, max_bytes=MAX_RESOURCE_BYTES)
|
||||
mime = "text/markdown" if Path(path).suffix.lower() == ".md" else "text/plain"
|
||||
@@ -352,6 +366,12 @@ def _propose(user: dict[str, Any], tool: str, arguments: dict[str, Any]) -> list
|
||||
params = spec.input_model.model_validate(arguments)
|
||||
vault = getattr(params, "vault", None)
|
||||
ctx = ToolContext(user=user, mode=ToolMode.MCP)
|
||||
# #199 : les tokens à portée limitée ne peuvent proposer que les outils
|
||||
# fichiers (même garde que call_tool, qui couvre apply_).
|
||||
try:
|
||||
ctx.require_tool_allowed(tool)
|
||||
except ToolError as e:
|
||||
return _text(e.to_dict())
|
||||
if vault and vault != "all":
|
||||
ctx.require_vault_access(vault)
|
||||
if spec.risk == ToolRisk.DANGEROUS:
|
||||
|
||||
+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)
|
||||
|
||||
@@ -28,6 +28,8 @@ TAGS_METADATA: list[dict[str, str]] = [
|
||||
{"name": "Vaults", "description": "List, add and remove vaults; per-vault display settings; attachment indexing."},
|
||||
{"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."},
|
||||
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
|
||||
{"name": "ExternalRefs", "description": "Cross-application references from a note to Flowdeck, TikTak, Kanbante or ObsiGate objects (Fusion program, ACC-1.3)."},
|
||||
{"name": "SSO", "description": "draco_session single sign-on emitter: authorize entry point and jti revocation list for the other dracodev apps (Fusion program, ACC-3.1)."},
|
||||
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
|
||||
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
|
||||
{"name": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."},
|
||||
@@ -39,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)."},
|
||||
@@ -114,6 +117,8 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
|
||||
(re.compile(r"^/api/graph"), "Search"),
|
||||
(re.compile(r"^/api/recent"), "Bookmarks"),
|
||||
(re.compile(r"^/api/bookmarks"), "Bookmarks"),
|
||||
(re.compile(r"^/api/refs"), "ExternalRefs"),
|
||||
(re.compile(r"^/api/sso"), "SSO"),
|
||||
(re.compile(r"^/api/saved-searches"), "Bookmarks"),
|
||||
(re.compile(r"^/api/vaults"), "Vaults"),
|
||||
(re.compile(r"^/api/vault/"), "Vaults"),
|
||||
@@ -242,8 +247,8 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
||||
"response": {"id": "abc…", "token": "abc…", "vault": "TestVault", "path": "notes/Accueil.md", "url": "/s/abc…"},
|
||||
},
|
||||
("post", "/api/webhooks"): {
|
||||
"request": {"name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "secret": "s3cr3t"},
|
||||
"response": {"id": "3f2c…", "name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "enabled": True},
|
||||
"request": {"name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "secret": "s3cr3t", "vault": "TikTak", "path_prefix": "Inbox"},
|
||||
"response": {"id": "3f2c…", "name": "CI", "url": "https://example.com/hook", "events": ["file_modified"], "enabled": True, "vault": "TikTak", "path_prefix": "Inbox"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+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)
|
||||
+45
-10
@@ -5,7 +5,10 @@ python-frontmatter==1.1.0
|
||||
mistune==3.3.3
|
||||
python-multipart==0.0.31
|
||||
aiofiles==23.2.1
|
||||
aiohttp>=3.9.0
|
||||
# Plancher de sécurité : 3.14.3 est vulnérable à 8 CVE (dont
|
||||
# CVE-2026-111063/111064 et GHSA-2g87-pp6c-29x5), toutes corrigées en
|
||||
# 3.14.4 — pip-audit (CI, bloquant) exige de rester au-dessus.
|
||||
aiohttp>=3.14.4
|
||||
watchdog>=4.0.0
|
||||
argon2-cffi>=23.1.0
|
||||
python-jose>=3.3.0
|
||||
@@ -13,19 +16,24 @@ 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
|
||||
psutil>=5.9
|
||||
pywebpush>=2.3.0
|
||||
mcp==1.28.1
|
||||
# 1.28.1 vulnérable (CVE-2026-59951, fix 1.30.0) — épinglé sur la
|
||||
# première version corrigée de la même ligne 1.x.
|
||||
mcp==1.30.0
|
||||
# Plancher de sécurité (BUG-091, BUG-095) : pyjwt est une dépendance transitive
|
||||
# (mcp). 2.12.x → PYSEC-2026-178 (fix 2.13.0) ; 2.13.0 → CVE-2026-102274
|
||||
# (fix 2.14.0). pip-audit étant bloquant, on reste au-dessus du dernier correctif.
|
||||
@@ -36,7 +44,9 @@ xlrd==2.0.2
|
||||
odfpy==1.4.1
|
||||
python-docx>=1.1
|
||||
reportlab>=4.0
|
||||
pillow>=10.0
|
||||
# Plancher de sécurité : 12.3.0 est vulnérable à 8 CVE (dont
|
||||
# CVE-2026-112895 et CVE-2026-112400), toutes corrigées en 13.0.0.
|
||||
pillow>=13.0.0
|
||||
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
|
||||
urllib3>=2.8.0
|
||||
# Plancher de sécurité (CVE-2026-104874, fix 6.9.1) : multidict est transitive
|
||||
@@ -44,3 +54,28 @@ urllib3>=2.8.0
|
||||
# l'image du runner — sans plancher, pip répond « already satisfied » et
|
||||
# n'aligne jamais (même piège que pypdf, BUG-093).
|
||||
multidict>=6.9.1
|
||||
# Plancher de sécurité (PYSEC-2026-4320, fix 2026.10.19) : certifi est
|
||||
# transitive (httpx/requests) — sans plancher explicite, pip-audit
|
||||
# signale la version héritée de l'environnement.
|
||||
certifi>=2026.10.19
|
||||
# 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,776 @@
|
||||
"""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,
|
||||
est_expire,
|
||||
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"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/coffres/distribuables")
|
||||
async def api_distribuables(request: Request):
|
||||
"""Secrets distribuables pour un consommateur (issue #43).
|
||||
|
||||
Authentification par jeton consommateur (la même que
|
||||
/distribuer), jamais par session utilisateur : c'est le
|
||||
serveur agent qui appelle, pour alimenter la liste déroulante
|
||||
de ses fournisseurs LLM. Retour = métadonnées seules des
|
||||
secrets pour lesquels ce consommateur a un octroi ACTIF
|
||||
(coffre actif, secret non expiré, octroi ni révoqué ni
|
||||
expiré, y compris les octrois coffre-wide). Un consommateur
|
||||
sans octroi reçoit une liste vide — jamais d'erreur
|
||||
révélatrice, jamais de valeur.
|
||||
"""
|
||||
_exiger_actives()
|
||||
auth = request.headers.get("Authorization", "")
|
||||
jeton = auth[7:] if auth.startswith("Bearer ") else ""
|
||||
consommateur = verifier_jeton_consommateur(jeton)
|
||||
if consommateur is None:
|
||||
raise HTTPException(status_code=401,
|
||||
detail="consommateur_invalide")
|
||||
if consommateur["statut"] != "actif":
|
||||
raise HTTPException(status_code=403,
|
||||
detail="consommateur_revoque")
|
||||
sortie = []
|
||||
for coffre in list_coffres():
|
||||
if coffre["statut"] != "actif":
|
||||
continue
|
||||
for secret in list_secrets(coffre["id"]):
|
||||
if est_expire(secret.get("expiration")):
|
||||
continue
|
||||
statut, _octroi = statut_octroi_consommateur(
|
||||
consommateur["id"], coffre["id"], secret["id"])
|
||||
if statut != "ok":
|
||||
continue
|
||||
sortie.append({
|
||||
"reference": f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
"coffre": coffre["slug"],
|
||||
"nom": secret["nom"],
|
||||
"version": secret["version_courante"],
|
||||
})
|
||||
return sortie
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Endpoints des références croisées des notes — Fusion #215 (ACC-1.3).
|
||||
|
||||
Création par collage d'une URL profonde d'une autre application
|
||||
(Flowdeck, TikTak, Kanbante, ObsiGate) — zéro couplage serveur :
|
||||
l'URL est reconnue par les formes figées du contrat
|
||||
``dracodev-commons/contrats/liens-profonds.md`` (voir
|
||||
:mod:`backend.external_refs`), la référence est stockée chez la note
|
||||
propriétaire et affichée dans sa section « Liens ».
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.external_refs import add_ref, delete_ref, get_ref, list_refs, parse_deep_link
|
||||
from backend.schemas import ExternalRef, ExternalRefCreateRequest, ExternalRefsResponse
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
router = APIRouter(tags=["ExternalRefs"])
|
||||
|
||||
_FORMES_ACCEPTEES = (
|
||||
"URL non reconnue — formes acceptées : TikTak ?task= ou ?list=, "
|
||||
"ObsiGate #file=, Flowdeck /pages/ ou /collections/, "
|
||||
"issue Gitea (carte Kanbante)"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/refs", response_model=ExternalRefsResponse)
|
||||
async def api_list_refs(
|
||||
vault: str = Query(...),
|
||||
path: str = Query(...),
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Références croisées posées sur une note."""
|
||||
if not check_vault_access(vault, current_user):
|
||||
raise HTTPException(status_code=403, detail="Accès au vault refusé")
|
||||
username = current_user.get("username") or ""
|
||||
return {"refs": list_refs(username, vault, path)}
|
||||
|
||||
|
||||
@router.post("/api/refs", response_model=ExternalRef, status_code=201)
|
||||
async def api_add_ref(
|
||||
payload: ExternalRefCreateRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Crée une référence sur une note à partir d'une URL profonde collée."""
|
||||
if not check_vault_access(payload.vault, current_user):
|
||||
raise HTTPException(status_code=403, detail="Accès au vault refusé")
|
||||
parsed = parse_deep_link(payload.url)
|
||||
if parsed is None:
|
||||
raise HTTPException(status_code=422, detail=_FORMES_ACCEPTEES)
|
||||
target_app, target_type, target_id, default_label = parsed
|
||||
label = (payload.label or "").strip() or default_label
|
||||
username = current_user.get("username") or ""
|
||||
return add_ref(
|
||||
username,
|
||||
payload.vault,
|
||||
payload.path,
|
||||
target_app,
|
||||
target_type,
|
||||
target_id,
|
||||
payload.url.strip(),
|
||||
label,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api/refs/{ref_id}", response_model=ExternalRef)
|
||||
async def api_delete_ref(
|
||||
ref_id: str,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Retire une référence d'une note."""
|
||||
username = current_user.get("username") or ""
|
||||
ref = get_ref(username, ref_id)
|
||||
if ref is None:
|
||||
raise HTTPException(status_code=404, detail="Lien introuvable")
|
||||
if not check_vault_access(ref["owner_vault"], current_user):
|
||||
raise HTTPException(status_code=403, detail="Accès au vault refusé")
|
||||
removed = delete_ref(username, ref_id)
|
||||
if removed is None: # course improbable : disparue entre get et delete
|
||||
raise HTTPException(status_code=404, detail="Lien introuvable")
|
||||
return removed
|
||||
@@ -45,9 +45,46 @@ from backend.services.files import read_raw_file
|
||||
from backend.services.mutations import file_revision
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory, get_vault_root
|
||||
from backend.share import list_shares
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
|
||||
def _resolve_shared_file(vault_name: str, path: str, username: str | None):
|
||||
"""#196 — resolve ``home-<user>/Partage/<file>`` to the real source file.
|
||||
|
||||
Returns ``(real_vault, real_path)`` when *path* is a received share
|
||||
mounted in the user's personal folder, else ``None``. Read-only: only a
|
||||
recipient (or the share creator, whose own file is already accessible)
|
||||
gets a mapping — a share directed to someone else never resolves here.
|
||||
|
||||
Chemin canonique : ``Partage/<token>/<nom>`` (token = lève l'ambiguïté
|
||||
de deux partages au même nom). Fallback par nom seul pour les liens
|
||||
ne transportant pas le token.
|
||||
"""
|
||||
if not username or not vault_name.startswith("home-") or not path.startswith("Partage/"):
|
||||
return None
|
||||
owner = vault_name[len("home-"):]
|
||||
if owner != username:
|
||||
return None
|
||||
# Chemin virtuel canonique : Partage/<token>/<nom> — le token lève
|
||||
# l'ambiguïté (deux partages, même nom de base). Fallback : match par
|
||||
# nom pour les liens ne portant pas le token.
|
||||
rest = path.split("/", 1)[1]
|
||||
parts = rest.split("/", 1)
|
||||
if len(parts) == 2 and len(parts[0]) >= 20: # token (64 hex) vs nom de fichier
|
||||
token, _name = parts
|
||||
for s in list_shares(user=username):
|
||||
if s.get("token") == token and s.get("created_by") != username:
|
||||
return s["vault"], s["path"]
|
||||
return None
|
||||
for s in list_shares(user=username):
|
||||
if s.get("created_by") == username:
|
||||
continue
|
||||
if (s.get("path") or "").split("/")[-1] == rest:
|
||||
return s["vault"], s["path"]
|
||||
return None
|
||||
|
||||
# Map file extensions to highlight.js language hints
|
||||
EXT_TO_LANG = {
|
||||
".py": "python", ".js": "javascript", ".ts": "typescript",
|
||||
@@ -104,7 +141,11 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
|
||||
Returns:
|
||||
``FileRawResponse`` with vault, path, and raw text content.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
|
||||
if shared:
|
||||
# Partage dirigé = autorisation (cf. api_file).
|
||||
vault_name, path = shared
|
||||
elif not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
return read_raw_file(vault_name, path)
|
||||
|
||||
@@ -120,7 +161,11 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
|
||||
Returns:
|
||||
``FileResponse`` with ``application/octet-stream`` content-type.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
|
||||
if shared:
|
||||
# Partage dirigé = autorisation (cf. api_file).
|
||||
vault_name, path = shared
|
||||
elif not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
@@ -305,8 +350,18 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
Returns:
|
||||
``FileContentResponse`` with HTML, metadata, and tags.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
# #196 — fichier reçu par partage dirigé : home-<user>/Partage/<fichier>
|
||||
# est résolu vers le fichier source (lecture seule, viewer standard).
|
||||
# Résolu AVANT l'ACL vault : le dossier est virtuel, l'autorisation réelle
|
||||
# est l'appartenance au partage (vérifiée dans le resolver).
|
||||
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
|
||||
if shared:
|
||||
# Le partage dirigé EST l'autorisation : le destinataire n'a par
|
||||
# définition pas accès au vault source — on ne passe PAS par l'ACL.
|
||||
vault_name, path = shared
|
||||
elif not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
@@ -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 (
|
||||
@@ -34,6 +35,8 @@ from backend.schemas import (
|
||||
FileCreateRequest,
|
||||
FileCreateResponse,
|
||||
FileDeleteResponse,
|
||||
FileDuplicateRequest,
|
||||
FileDuplicateResponse,
|
||||
FileMoveRequest,
|
||||
FileMoveResponse,
|
||||
FileRenameRequest,
|
||||
@@ -55,6 +58,9 @@ from backend.services.mutations import (
|
||||
from backend.services.mutations import (
|
||||
delete_file as service_delete_file,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
duplicate_path as service_duplicate_path,
|
||||
)
|
||||
from backend.services.mutations import (
|
||||
edit_file as service_edit_file,
|
||||
)
|
||||
@@ -115,10 +121,22 @@ async def api_file_save(
|
||||
content = body.get("content", "")
|
||||
result = service_edit_file(vault_name, path, content, backup=backup)
|
||||
|
||||
# #197 (synchro TikTak) : rafraîchir l'index de façon synchrone pour une
|
||||
# cohérence read-after-write — un client qui liste juste après le save
|
||||
# doit voir le nouveau mtime/size sans attendre le watcher.
|
||||
await update_single_file(vault_name, result["path"])
|
||||
|
||||
# #198 : la sauvegarde texte émet file_modified (événement clé pour tirer
|
||||
# les retours de synchro côté TikTak en phase 3).
|
||||
await dispatch_webhooks("file_modified", {"vault": vault_name, "path": result["path"]})
|
||||
|
||||
# Audit log
|
||||
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"]}
|
||||
|
||||
|
||||
@@ -187,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"),
|
||||
@@ -236,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"),
|
||||
@@ -299,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"),
|
||||
@@ -367,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"),
|
||||
@@ -391,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)
|
||||
@@ -523,9 +548,10 @@ async def api_directory_delete(
|
||||
result = service_delete_directory(vault_name, path, recursive=True)
|
||||
file_count = result["deleted_count"]
|
||||
|
||||
# Update index
|
||||
from backend.indexer import reload_single_vault
|
||||
await reload_single_vault(vault_name)
|
||||
# Update index — incremental subtree removal (no full vault re-scan, no
|
||||
# global inverted-index rebuild).
|
||||
from backend.indexer import remove_directory_from_index
|
||||
await remove_directory_from_index(vault_name, result["path"])
|
||||
|
||||
# Broadcast SSE event
|
||||
await sse_manager.broadcast("directory_deleted", {
|
||||
@@ -562,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"])
|
||||
|
||||
@@ -671,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)
|
||||
|
||||
@@ -716,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
|
||||
@@ -733,3 +770,41 @@ async def api_file_move(
|
||||
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
|
||||
|
||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
|
||||
|
||||
@router.post("/api/duplicate/{vault_name}", response_model=FileDuplicateResponse)
|
||||
async def api_file_duplicate(
|
||||
vault_name: str,
|
||||
body: FileDuplicateRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Duplicate (copy) a file or directory within the same vault."""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
|
||||
result = service_duplicate_path(vault_name, body.source_path, body.destination_dir)
|
||||
new_path_str = result["new_path"]
|
||||
item_type = result["item_type"]
|
||||
|
||||
# Index: incrémental pour chaque fichier copié (évite un reload complet).
|
||||
from backend.services.vaults import get_vault_root
|
||||
root = get_vault_root(vault_name)
|
||||
target = root / new_path_str
|
||||
if item_type == "directory":
|
||||
for fp in sorted(target.rglob("*")):
|
||||
if fp.is_file():
|
||||
await update_single_file(vault_name, str(fp.relative_to(root)).replace("\\", "/"))
|
||||
else:
|
||||
await update_single_file(vault_name, new_path_str)
|
||||
|
||||
await sse_manager.broadcast("item_duplicated", {
|
||||
"vault": vault_name,
|
||||
"source_path": result["source_path"],
|
||||
"new_path": new_path_str,
|
||||
"item_type": item_type,
|
||||
})
|
||||
await dispatch_webhooks(
|
||||
"file_created" if item_type == "file" else "directory_created",
|
||||
{"vault": vault_name, "path": new_path_str},
|
||||
)
|
||||
|
||||
return {"success": True, "source_path": result["source_path"], "new_path": new_path_str, "item_type": item_type}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -84,9 +84,12 @@ async def api_search(
|
||||
get_search_executor(),
|
||||
# #194 : filtre par vault accessible AVANT pagination ("*" sans les
|
||||
# dossiers persos) — sinon un user voyait les notes des autres.
|
||||
# #196 : les documents reçus par partage dirigé sont ajoutés aux
|
||||
# résultats (vault virtuel "home-<user>/Partage").
|
||||
partial(
|
||||
search_vaults, q, vault, tag, limit, offset,
|
||||
is_allowed=lambda v: check_vault_access(v, current_user),
|
||||
username=current_user["username"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -88,7 +88,9 @@ async def api_share_create(
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
path = body.get("path", "")
|
||||
path = body.get("path") or ""
|
||||
if not path:
|
||||
raise HTTPException(400, "Chemin de fichier requis")
|
||||
expires = body.get("expires_in_hours")
|
||||
# #196 — directed share: validate recipients before creating anything.
|
||||
recipients = body.get("shared_with") or []
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# backend/routers/sso.py
|
||||
# Fusion ACC-3.1 (#216) — Points d'entrée SSO de l'émettrice.
|
||||
#
|
||||
# GET /sso/authorize?return_to=<url> : point d'entrée des autres
|
||||
# applications — valide le return_to (liste blanche *.dracodev.net,
|
||||
# anti open-redirect) puis redirige vers la page de connexion
|
||||
# d'ObsiGate en conservant le paramètre ; après connexion, le
|
||||
# frontend repart vers l'application demanderesse (le cookie
|
||||
# draco_session posé à la connexion couvre tout le domaine parent).
|
||||
#
|
||||
# GET /api/sso/revocations : liste des jti révoqués, réservée aux
|
||||
# autres applications (clé machine DRACO_SSO_MACHINE_KEY, en-tête
|
||||
# X-Draco-Machine-Key) qui la mettent en cache 60 s (design §5.2).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from backend import draco_session
|
||||
|
||||
router = APIRouter(tags=["SSO"])
|
||||
|
||||
|
||||
@router.get("/sso/authorize")
|
||||
async def sso_authorize(return_to: str = ""):
|
||||
target = draco_session.validate_return_to(return_to)
|
||||
if target:
|
||||
return RedirectResponse(
|
||||
url=f"/?return_to={quote(target, safe='')}", status_code=302)
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
|
||||
@router.get("/api/sso/revocations")
|
||||
async def sso_revocations(request: Request):
|
||||
machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "")
|
||||
if not machine_key:
|
||||
raise HTTPException(404, "Non configuré")
|
||||
if request.headers.get("X-Draco-Machine-Key") != machine_key:
|
||||
raise HTTPException(401, "Clé machine invalide")
|
||||
return {"revoked": draco_session.revoked_list()}
|
||||
@@ -34,7 +34,9 @@ async def api_webhooks_create(body: dict = Body(...), current_user=Depends(requi
|
||||
secret = body.get("secret")
|
||||
if not url:
|
||||
raise HTTPException(400, "URL is required")
|
||||
return create_webhook(name, url, events, secret)
|
||||
# #198 : filtres optionnels vault / path_prefix (None = tout recevoir).
|
||||
return create_webhook(name, url, events, secret,
|
||||
vault=body.get("vault"), path_prefix=body.get("path_prefix"))
|
||||
|
||||
|
||||
@router.patch("/{webhook_id}", response_model=WebhookModel)
|
||||
|
||||
+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:
|
||||
|
||||
+62
-1
@@ -300,6 +300,8 @@ class BrowseItem(BaseModel):
|
||||
children_count: int | None = Field(default=None, description="Number of children (directories only)")
|
||||
size: int | None = Field(default=None, description="File size in bytes")
|
||||
extension: str | None = Field(default=None, description="File extension")
|
||||
modified: float | None = Field(default=None, description="Modification time, epoch seconds (#197)")
|
||||
modified_iso: str | None = Field(default=None, description="Modification time, ISO 8601 (#197)")
|
||||
|
||||
|
||||
class BrowseResponse(BaseModel):
|
||||
@@ -578,6 +580,20 @@ class FileMoveResponse(BaseModel):
|
||||
success: bool = Field(description="Whether move succeeded")
|
||||
old_path: str = Field(description="Original path")
|
||||
new_path: str = Field(description="New path after move")
|
||||
|
||||
class FileDuplicateRequest(BaseModel):
|
||||
"""Request to duplicate (copy) a file or directory."""
|
||||
|
||||
source_path: str = Field(description="Relative path of the item to duplicate")
|
||||
destination_dir: str = Field(default="", description="Target directory relative path (empty = source parent)")
|
||||
|
||||
|
||||
class FileDuplicateResponse(BaseModel):
|
||||
"""Response after duplicating a file or directory."""
|
||||
|
||||
success: bool = Field(description="Whether duplication succeeded")
|
||||
source_path: str = Field(description="Original path")
|
||||
new_path: str = Field(description="Path of the created copy")
|
||||
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
|
||||
|
||||
|
||||
@@ -603,6 +619,44 @@ class BookmarkToggleRequest(BaseModel):
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class ExternalRef(BaseModel):
|
||||
"""Référence croisée d'une note vers un objet d'une autre application.
|
||||
|
||||
Modèle figé par dracodev-commons (contrats/external_ref.schema.json,
|
||||
v0.1.0) — Fusion #215 (ACC-1.3).
|
||||
"""
|
||||
|
||||
id: str
|
||||
owner_type: str = Field(description="Toujours 'obsigate.note' côté ObsiGate")
|
||||
owner_id: str = Field(description="'<vault>:<chemin>' de la note propriétaire")
|
||||
owner_vault: str
|
||||
owner_path: str
|
||||
target_app: str = Field(description="obsigate | flowdeck | tiktak | kanbante")
|
||||
target_type: str = Field(description="note | project | page | task | list | card")
|
||||
target_id: str
|
||||
target_url: str = Field(description="URL profonde selon contrats/liens-profonds.md")
|
||||
label: str = Field(description="Instantané d'affichage du titre de la cible")
|
||||
domain: str | None = None
|
||||
status: str = Field(default="ok", description="ok | target_missing")
|
||||
created_by: str = Field(default="user")
|
||||
created_at: str
|
||||
|
||||
|
||||
class ExternalRefCreateRequest(BaseModel):
|
||||
"""Création d'une référence par collage d'URL profonde."""
|
||||
|
||||
vault: str
|
||||
path: str
|
||||
url: str
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class ExternalRefsResponse(BaseModel):
|
||||
"""Liste des références d'une note."""
|
||||
|
||||
refs: list[ExternalRef]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -611,13 +665,15 @@ class BookmarkToggleRequest(BaseModel):
|
||||
class SearchResultItem(BaseModel):
|
||||
"""A single search result."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
vault: str = Field(description="Vault name")
|
||||
path: str = Field(description="Relative file path")
|
||||
title: str = Field(description="File title")
|
||||
tags: list[str] = Field(description="File tags")
|
||||
score: int = Field(description="Relevance score")
|
||||
snippet: str = Field(description="Content excerpt with highlights")
|
||||
modified: str = Field(description="ISO 8601 modification timestamp")
|
||||
modified: str | None = Field(default=None, description="ISO 8601 modification timestamp")
|
||||
share_token: str | None = Field(default=None, description="#196 token de partage dirigé (document reçu)")
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
@@ -665,6 +721,9 @@ class VaultPathEntry(BaseModel):
|
||||
path: str = Field(description="Full relative path")
|
||||
name: str = Field(description="File or directory name")
|
||||
type: str = Field(description="'file' or 'directory'")
|
||||
size: int | None = Field(default=None, description="File size in bytes (#197, files only)")
|
||||
mtime: float | None = Field(default=None, description="Modification time, epoch seconds (#197, files only)")
|
||||
mtime_iso: str | None = Field(default=None, description="Modification time, ISO 8601 (#197, files only)")
|
||||
|
||||
|
||||
class VaultPathsResponse(BaseModel):
|
||||
@@ -1040,6 +1099,8 @@ class WebhookModel(BaseModel):
|
||||
enabled: bool = True
|
||||
created_at: str | None = None
|
||||
last_fired_at: str | None = None
|
||||
vault: str | None = Field(default=None, description="#198 vault filter (None = all vaults)")
|
||||
path_prefix: str | None = Field(default=None, description="#198 path-prefix filter (None = whole vault)")
|
||||
|
||||
|
||||
class ShareModel(BaseModel):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1534,6 +1534,80 @@ def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> d
|
||||
"item_type": item_type,
|
||||
}
|
||||
|
||||
def duplicate_path(vault_name: str, source_path: str, destination_dir: str = "") -> dict[str, Any]:
|
||||
"""Duplicate a file or directory (copy) into *destination_dir* (same vault).
|
||||
|
||||
The copy is named ``<stem> copie<ext>`` (then ``copie 2``, ``copie 3``… on
|
||||
collision). Directories are copied recursively.
|
||||
|
||||
Raises:
|
||||
ServiceError: ``not_found`` (404), ``read_only`` (403),
|
||||
``permission_denied`` (403), ``invalid_arguments`` (400) when the
|
||||
destination is inside the source directory.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
|
||||
source = resolve_safe_path(root, source_path)
|
||||
if not source.exists():
|
||||
raise ServiceError(
|
||||
f"Source not found: {source_path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": source_path},
|
||||
)
|
||||
|
||||
is_directory = source.is_dir()
|
||||
dest_clean = (destination_dir or "").strip("/")
|
||||
if dest_clean:
|
||||
dest_parent = resolve_safe_path(root, dest_clean)
|
||||
if not dest_parent.exists() or not dest_parent.is_dir():
|
||||
raise ServiceError(
|
||||
f"Destination directory not found: {destination_dir}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": destination_dir},
|
||||
)
|
||||
else:
|
||||
dest_parent = root
|
||||
|
||||
if is_directory and str(dest_parent.resolve()) in (str(source.resolve()), str(source.resolve()) + os.sep, str(source.resolve()) + "/"):
|
||||
raise ServiceError(
|
||||
"Cannot duplicate a directory inside itself",
|
||||
code="invalid_arguments",
|
||||
status=400,
|
||||
details={"vault": vault_name, "path": source_path},
|
||||
)
|
||||
|
||||
ext = "" if is_directory else source.suffix
|
||||
base = dest_parent / (source.stem + " copie" + ext)
|
||||
target = base
|
||||
n = 2
|
||||
while target.exists():
|
||||
target = base.parent / (source.stem + " copie " + str(n) + ext)
|
||||
n += 1
|
||||
|
||||
try:
|
||||
if is_directory:
|
||||
shutil.copytree(source, target)
|
||||
else:
|
||||
shutil.copy2(source, target)
|
||||
except PermissionError as e:
|
||||
raise ServiceError("Permission denied: cannot duplicate item", code="permission_denied", status=403) from e
|
||||
|
||||
new_rel = _rel(root, target)
|
||||
item_type = "directory" if is_directory else "file"
|
||||
logger.info(f"Item duplicated: {vault_name}/{source_path} -> {new_rel}")
|
||||
return {
|
||||
"success": True,
|
||||
"vault": vault_name,
|
||||
"source_path": _rel(root, source),
|
||||
"new_path": new_rel,
|
||||
"item_type": item_type,
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
# ── D3. Find & replace ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+105
-6
@@ -13,18 +13,28 @@ def search_vaults(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
is_allowed: Callable[[str], bool] | None = None,
|
||||
username: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Full-text search with pagination, returned as the API response payload.
|
||||
|
||||
``is_allowed`` (#194) filters the raw hits **before** pagination, so a
|
||||
restricted vault set neither distorts ``total`` nor the returned page.
|
||||
The tool layer filters on its own and leaves it as ``None``.
|
||||
|
||||
``username`` (#196) : les documents **reçus** par cet utilisateur via un
|
||||
partage dirigé sont ajoutés aux résultats (lecture seule, sans indexer
|
||||
les fichiers d'autrui — le contenu est lu à la volée et mis en cache
|
||||
TF-IDF côté index, pas sur disque).
|
||||
"""
|
||||
from backend.search import search
|
||||
|
||||
all_results = search(q, vault_filter=vault, tag_filter=tag)
|
||||
if is_allowed is not None:
|
||||
all_results = [r for r in all_results if is_allowed(r.get("vault", ""))]
|
||||
|
||||
if username is not None:
|
||||
all_results = all_results + _shared_results(username, q, vault)
|
||||
|
||||
total = len(all_results)
|
||||
page = all_results[offset: offset + limit]
|
||||
return {
|
||||
@@ -39,6 +49,67 @@ def search_vaults(
|
||||
}
|
||||
|
||||
|
||||
def _shared_results(username: str, q: str, vault: str) -> list[dict[str, Any]]:
|
||||
"""Search results for documents shared TO *username* (#196).
|
||||
|
||||
Reads the shared file content on the fly (read-only, source vault on
|
||||
disk) and returns hits shaped exactly like ordinary search results so
|
||||
the frontend can open them via the normal share page.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from backend.indexer import get_vault_data
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.share import list_shares
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
if not q:
|
||||
return out
|
||||
q_lower = q.lower()
|
||||
|
||||
for s in list_shares(user=username):
|
||||
if s.get("created_by") == username:
|
||||
continue # own share — already indexed in its own vault
|
||||
if vault not in ("all", f"home-{username}"):
|
||||
continue
|
||||
data = get_vault_data(s["vault"])
|
||||
if not data:
|
||||
continue
|
||||
try:
|
||||
fp = resolve_safe_path(Path(data["path"]), s["path"])
|
||||
if not fp.exists() or fp.suffix.lower() != ".md":
|
||||
continue
|
||||
raw = fp.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception: # nosec B112 — partage illisible : ignoré, on continue
|
||||
continue
|
||||
title = fp.stem
|
||||
occurrences = raw.lower().count(q_lower)
|
||||
if q_lower in title.lower():
|
||||
occurrences += 1
|
||||
if occurrences == 0:
|
||||
continue
|
||||
out.append({
|
||||
"vault": f"home-{username}",
|
||||
"path": f"Partage/{fp.name}",
|
||||
"title": title,
|
||||
"tags": [],
|
||||
"score": min(occurrences, 10),
|
||||
"snippet": _snippet(raw, q_lower),
|
||||
"modified": None,
|
||||
"share_token": s["token"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _snippet(text: str, q_lower: str, width: int = 160) -> str:
|
||||
"""Small excerpt around the first match (#196, shared-file search)."""
|
||||
idx = text.lower().find(q_lower)
|
||||
if idx < 0:
|
||||
return text[:width]
|
||||
start = max(0, idx - width // 2)
|
||||
return "…" + text[start:start + width].replace("\n", " ") + "…"
|
||||
|
||||
|
||||
def list_tags(vault: str | None = None) -> dict[str, int]:
|
||||
"""Return tag → count, optionally restricted to a single vault."""
|
||||
from backend.search import get_all_tags
|
||||
@@ -127,23 +198,51 @@ def list_paths(vault: str, limit: int = 5000) -> dict[str, Any]:
|
||||
once lets the client filter files/directories instantly instead of issuing
|
||||
a request per keystroke.
|
||||
|
||||
#197 (synchro TikTak) : chaque entrée fichier expose aussi ``size``,
|
||||
``mtime`` (epoch) et ``mtime_iso`` — un client externe peut détecter les
|
||||
fichiers modifiés depuis un horodatage en **un seul appel**, sans relire
|
||||
chaque fichier. Les valeurs viennent de l'index (source unique, même
|
||||
contrat que ``list_all_files``) ; les répertoires portent ``None``.
|
||||
|
||||
Args:
|
||||
vault: Vault name.
|
||||
limit: Maximum number of entries returned.
|
||||
|
||||
Returns:
|
||||
``{"vault", "count", "results": [{vault, path, name, type}]}``.
|
||||
``{"vault", "count", "results": [{vault, path, name, type, size?,
|
||||
mtime?, mtime_iso?}]}``.
|
||||
"""
|
||||
from backend.indexer import path_index
|
||||
from datetime import datetime
|
||||
|
||||
from backend.indexer import get_vault_data, path_index
|
||||
|
||||
entries = path_index.get(vault, [])
|
||||
results = [
|
||||
{
|
||||
meta: dict[str, dict[str, Any]] = {}
|
||||
data = get_vault_data(vault)
|
||||
if data:
|
||||
for info in data.get("files", []):
|
||||
meta[info["path"]] = info
|
||||
|
||||
results = []
|
||||
for entry in entries[: max(0, limit)]:
|
||||
item: dict[str, Any] = {
|
||||
"vault": vault,
|
||||
"path": entry["path"],
|
||||
"name": entry["name"],
|
||||
"type": entry["type"],
|
||||
"size": None,
|
||||
"mtime": None,
|
||||
"mtime_iso": None,
|
||||
}
|
||||
for entry in entries[: max(0, limit)]
|
||||
]
|
||||
info = meta.get(entry["path"])
|
||||
if info is not None:
|
||||
item["size"] = info.get("size")
|
||||
iso = info.get("modified")
|
||||
item["mtime_iso"] = iso
|
||||
if iso:
|
||||
try:
|
||||
item["mtime"] = datetime.fromisoformat(iso).timestamp()
|
||||
except (ValueError, TypeError):
|
||||
item["mtime"] = None
|
||||
results.append(item)
|
||||
return {"vault": vault, "count": len(results), "results": results}
|
||||
|
||||
@@ -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,6 +50,8 @@ 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
|
||||
|
||||
from backend.indexer import SUPPORTED_EXTENSIONS
|
||||
from backend.vault_settings import get_vault_setting
|
||||
|
||||
@@ -89,11 +92,14 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
"children_count": file_count,
|
||||
})
|
||||
elif entry.suffix.lower() in SUPPORTED_EXTENSIONS or entry.name.lower() in ("dockerfile", "makefile"):
|
||||
stat = entry.stat()
|
||||
items.append({
|
||||
"name": entry.name,
|
||||
"path": rel,
|
||||
"type": "file",
|
||||
"size": entry.stat().st_size,
|
||||
"size": stat.st_size,
|
||||
"modified": stat.st_mtime,
|
||||
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
|
||||
"extension": entry.suffix.lower(),
|
||||
})
|
||||
except PermissionError:
|
||||
@@ -151,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
|
||||
|
||||
@@ -210,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)
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from backend.tools import service as _service # noqa: F401 (registers tools)
|
||||
from backend.tools import spreadsheets as _spreadsheets # noqa: F401 (registers existing-workbook tools #153 A6)
|
||||
from backend.tools import web as _web # noqa: F401 (registers web tools)
|
||||
from backend.tools.context import (
|
||||
FILES_SCOPE_TOOLS,
|
||||
ToolConfirmationRequired,
|
||||
ToolContext,
|
||||
ToolError,
|
||||
@@ -52,6 +53,7 @@ from backend.tools.registry import (
|
||||
from backend.tools.schemas import ToolResult
|
||||
|
||||
__all__ = [
|
||||
"FILES_SCOPE_TOOLS",
|
||||
"ToolConfirmationRequired",
|
||||
"ToolContext",
|
||||
"ToolError",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -117,6 +117,29 @@ class ToolRateLimitError(ToolError):
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
#: Tools a ``files``-scoped API token (#199, synchro TikTak) may invoke :
|
||||
#: vault file read/write surface only — no external calls (git/web), no
|
||||
#: notifications, no scheduler, no cross-vault analysis beyond file content.
|
||||
FILES_SCOPE_TOOLS: frozenset[str] = frozenset({
|
||||
# Lecture / recherche de fichiers
|
||||
"list_vaults", "list_directory", "list_all_files",
|
||||
"read_file", "read_file_raw", "get_backlinks",
|
||||
"get_graph", "search_fulltext", "search_advanced", "search_paths",
|
||||
"list_tags", "suggest_tags", "list_recent",
|
||||
"list_backups", "diff_backup", "restore_backup",
|
||||
"find_duplicates",
|
||||
# Mutations de fichiers
|
||||
"create_file", "create_directory", "edit_file", "append_to_file",
|
||||
"rename_file", "rename_directory", "move_path", "replace_in_files",
|
||||
"delete_file", "delete_directory", "merge_duplicate_notes",
|
||||
# Tableurs & documents (création/édition de fichiers du vault)
|
||||
"create_xlsx", "create_docx", "create_csv", "create_pdf",
|
||||
"list_xlsx_sheets", "xlsx_to_markdown", "search_workbook",
|
||||
"analyze_range", "update_xlsx_cells", "append_xlsx_rows",
|
||||
"edit_xlsx_structure",
|
||||
})
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolContext:
|
||||
"""Carries the caller identity and execution options for a tool call.
|
||||
@@ -190,6 +213,32 @@ class ToolContext:
|
||||
details={"vault": vault},
|
||||
)
|
||||
|
||||
def require_tool_allowed(self, tool: str) -> None:
|
||||
"""Raise :class:`ToolPermissionError` when the caller's token scope
|
||||
forbids *tool* (#199).
|
||||
|
||||
A ``files``-scoped token is confined to :data:`FILES_SCOPE_TOOLS`
|
||||
(vault file surface). Full-scope and scopeless callers (session
|
||||
tokens, pre-#199 API keys) are unaffected ; an unknown scope fails
|
||||
closed.
|
||||
"""
|
||||
token_scope = self.user.get("_token_scope") or "full"
|
||||
if token_scope == "full":
|
||||
return
|
||||
if token_scope == "files":
|
||||
if tool not in FILES_SCOPE_TOOLS:
|
||||
raise ToolPermissionError(
|
||||
f"Token à portée limitée : outil '{tool}' hors périmètre 'files'",
|
||||
code="token_scope_denied",
|
||||
details={"tool": tool, "scope": "files"},
|
||||
)
|
||||
return
|
||||
raise ToolPermissionError(
|
||||
"Token à portée inconnue : appel refusé",
|
||||
code="token_scope_denied",
|
||||
details={"tool": tool},
|
||||
)
|
||||
|
||||
|
||||
def resolve_safe_path(vault_root: Path, relative_path: str | None) -> Path:
|
||||
"""Resolve a vault-relative path, rejecting traversal outside the vault.
|
||||
|
||||
@@ -216,6 +216,14 @@ def call_tool(
|
||||
_audit(ctx, spec, arguments, ok=False, error=e.code)
|
||||
raise
|
||||
|
||||
# #199 : les tokens à portée limitée sont confinés à la surface fichiers,
|
||||
# y compris pour les outils sans vault (git, web, notify…).
|
||||
try:
|
||||
ctx.require_tool_allowed(name)
|
||||
except ToolPermissionError as e:
|
||||
_audit(ctx, spec, arguments, ok=False, error=e.code)
|
||||
raise
|
||||
|
||||
if spec.risk == ToolRisk.DANGEROUS and vault and vault != "all":
|
||||
try:
|
||||
ctx.require_destructive_allowed(vault)
|
||||
|
||||
+57
-11
@@ -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,
|
||||
@@ -123,10 +124,17 @@ def list_vaults(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, An
|
||||
def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[str, Any]]:
|
||||
"""Return the entries of a vault directory (direct children only)."""
|
||||
data = browse_directory(params.vault, params.path)
|
||||
return [
|
||||
{"name": item["name"], "path": item["path"], "type": item["type"]}
|
||||
for item in data["items"]
|
||||
]
|
||||
# #197 : même contrat que la REST — la taille et le mtime permettent la
|
||||
# réconciliation externe sans relire chaque fichier.
|
||||
out = []
|
||||
for item in data["items"]:
|
||||
entry = {"name": item["name"], "path": item["path"], "type": item["type"]}
|
||||
if item["type"] == "file":
|
||||
entry["size"] = item.get("size")
|
||||
entry["mtime"] = item.get("modified")
|
||||
entry["mtime_iso"] = item.get("modified_iso")
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
@tool(
|
||||
@@ -353,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=(
|
||||
@@ -367,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(
|
||||
@@ -395,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(
|
||||
@@ -407,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(
|
||||
@@ -419,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(
|
||||
@@ -443,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(
|
||||
@@ -490,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()
|
||||
|
||||
+93
-7
@@ -16,6 +16,9 @@ Security (BUG-026):
|
||||
|
||||
Events: file_created, file_deleted, file_modified, file_renamed,
|
||||
directory_created, directory_deleted, directory_renamed
|
||||
(#198 : chaque webhook peut porter un filtre optionnel ``vault`` et/ou
|
||||
``path_prefix`` — seuls les événements correspondants sont livrés ; la
|
||||
charge utile inclut toujours ``vault`` et le(s) chemin(s)).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -28,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
|
||||
|
||||
@@ -120,6 +123,65 @@ def is_safe_target(url: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_vault_filter(vault: str | None) -> str | None:
|
||||
"""Normalize an optional vault filter (``None``/empty = no filtering)."""
|
||||
if not vault or not isinstance(vault, str):
|
||||
return None
|
||||
cleaned = vault.strip()
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _normalize_prefix_filter(prefix: str | None) -> str | None:
|
||||
"""Normalize an optional path-prefix filter (``None``/empty = no filtering)."""
|
||||
if not prefix or not isinstance(prefix, str):
|
||||
return None
|
||||
cleaned = prefix.strip().strip("/")
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _event_paths(data: dict) -> list[str]:
|
||||
"""Vault-relative paths carried by a webhook payload.
|
||||
|
||||
File/directory events use ``path``; renames and moves carry
|
||||
``old_path``/``new_path`` instead.
|
||||
"""
|
||||
paths = []
|
||||
for key in ("path", "old_path", "new_path"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
paths.append(value.strip("/"))
|
||||
return paths
|
||||
|
||||
|
||||
def webhook_matches(wh: dict, event_type: str, data: dict) -> bool:
|
||||
"""True when webhook *wh* must receive (*event_type*, *data*).
|
||||
|
||||
Pure function (no I/O) so the filtering is unit-testable :
|
||||
- event subscription (as before) ;
|
||||
- #198 optional ``vault`` filter — exact match on ``data["vault"]`` ;
|
||||
- #198 optional ``path_prefix`` filter — matches when any carried path
|
||||
equals the prefix or lies underneath it (``Docs`` matches ``Docs`` and
|
||||
``Docs/a.md``, not ``Docs2/b.md``).
|
||||
|
||||
Webhooks created before #198 carry neither key and keep receiving
|
||||
everything they subscribe to (backward compatible).
|
||||
"""
|
||||
if event_type not in wh.get("events", []):
|
||||
return False
|
||||
vault_filter = _normalize_vault_filter(wh.get("vault"))
|
||||
if vault_filter and data.get("vault") != vault_filter:
|
||||
return False
|
||||
prefix = _normalize_prefix_filter(wh.get("path_prefix"))
|
||||
if prefix:
|
||||
wanted = prefix + "/"
|
||||
for candidate in _event_paths(data):
|
||||
if candidate == prefix or candidate.startswith(wanted):
|
||||
break
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _read() -> list:
|
||||
if not WEBHOOKS_FILE.exists():
|
||||
return []
|
||||
@@ -195,7 +257,14 @@ def get_webhooks() -> list:
|
||||
return [_public_view(wh) for wh in _read()]
|
||||
|
||||
|
||||
def create_webhook(name: str, url: str, events: list[str], secret: str | None = None) -> dict:
|
||||
def create_webhook(
|
||||
name: str,
|
||||
url: str,
|
||||
events: list[str],
|
||||
secret: str | None = None,
|
||||
vault: str | None = None,
|
||||
path_prefix: str | None = None,
|
||||
) -> dict:
|
||||
validate_webhook_url(url)
|
||||
with _lock:
|
||||
webhooks = _read()
|
||||
@@ -206,8 +275,11 @@ def create_webhook(name: str, url: str, events: list[str], secret: str | None =
|
||||
"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),
|
||||
"path_prefix": _normalize_prefix_filter(path_prefix),
|
||||
}
|
||||
webhooks.append(wh)
|
||||
_write(webhooks)
|
||||
@@ -226,6 +298,13 @@ def update_webhook(wh_id: str, updates: dict) -> dict | None:
|
||||
validate_webhook_url(updates["url"])
|
||||
if "secret" in updates:
|
||||
_store_secret(wh_id, updates["secret"])
|
||||
# #198 : normaliser les filtres ("" réinitialise le filtre).
|
||||
for key, normalizer in (
|
||||
("vault", _normalize_vault_filter),
|
||||
("path_prefix", _normalize_prefix_filter),
|
||||
):
|
||||
if key in updates:
|
||||
updates[key] = normalizer(updates[key])
|
||||
safe_updates = {
|
||||
k: v for k, v in updates.items()
|
||||
if k not in ("id", "secret")
|
||||
@@ -250,15 +329,22 @@ def delete_webhook(wh_id: str) -> bool:
|
||||
|
||||
|
||||
async def dispatch_webhooks(event_type: str, data: dict):
|
||||
"""Fire all enabled webhooks subscribed to event_type."""
|
||||
"""Fire all enabled webhooks subscribed to event_type.
|
||||
|
||||
#198 : les webhooks portant un filtre ``vault``/``path_prefix`` ne sont
|
||||
livrés que pour les événements correspondants (voir :func:`webhook_matches`).
|
||||
"""
|
||||
webhooks = _read()
|
||||
targets = [wh for wh in webhooks if wh.get("enabled", True) and event_type in wh.get("events", [])]
|
||||
targets = [
|
||||
wh for wh in webhooks
|
||||
if wh.get("enabled", True) and webhook_matches(wh, event_type, data)
|
||||
]
|
||||
if not targets:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"event": event_type,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"data": data,
|
||||
}
|
||||
body = json.dumps(payload, default=str)
|
||||
@@ -283,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.66.0"
|
||||
version = "2.81.4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.66.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.66.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,239 @@
|
||||
Je vais analyser la solution Assistant V2. Commençons par explorer la structure du projet et localiser les références à "Assistant V2".
|
||||
|
||||
|
||||
|
||||
Il y a un dossier `docs/Assistant-V2/` avec plusieurs documents de spécification. Explorons-le en détail.
|
||||
|
||||
|
||||
|
||||
Let me lire le document principal et les documents de phase.
|
||||
|
||||
|
||||
|
||||
Continuons la lecture du document principal puis des phases.
|
||||
|
||||
|
||||
|
||||
Maintenant, lisons les documents de phase V2.1. Vérifions d'abord leur taille.
|
||||
|
||||
|
||||
|
||||
Ces documents sont volumineux. Je vais déléguer le résumé des phases 2 à 5 à des sous-agents en parallèle, pendant que je lis moi-même la phase 0 (fondations) et la phase 1, et que je vérifie l'état réel du code.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
The sub-agents reference a parent document `ASSISTANT-V2.1.md`. Let me check whether it exists, read phases 0 and 1 myself, and inspect the actual backend state.
|
||||
|
||||
|
||||
|
||||
Now let me read phase 1 and verify the actual codebase state — whether any of this exists (memory store, runs, policies) and how it's tracked in the roadmap.
|
||||
|
||||
|
||||
|
||||
Excellent picture forming. Let me verify the missing parent document references, and how (if at all) this is registered in the roadmap/todolist.
|
||||
|
||||
|
||||
|
||||
Key finding: IDs may collide with existing roadmap items. Let me inspect the roadmap's current/upcoming sections and the highest IDs in use.
|
||||
|
||||
|
||||
|
||||
Let me do a final quick check on dependencies and existing data stores to ground the component list.
|
||||
|
||||
|
||||
|
||||
J'ai lu l'intégralité de la documentation `docs/Assistant-V2/` (7 documents), vérifié l'état réel du code, et croisé le tout avec `ROADMAP.md`, `ISSUES_TODOLIST.md` et les conventions `AGENTS.md`. Voici l'analyse complète.
|
||||
|
||||
---
|
||||
|
||||
## 0. Verdict en une phrase
|
||||
|
||||
**Assistant V2 (et sa suite V2.1) est aujourd'hui une conception de très bonne qualité, mais 100 % non implémentée, non enregistrée dans le Roadmap, et dont le plan d'IDs est cassé par des collisions avec des features déjà livrées.** Le contenu technique est solide et réutilise finement l'existant ; la dette est d'abord méthodologique et documentaire, pas architecturale.
|
||||
|
||||
---
|
||||
|
||||
## 1. Cartographie des documents
|
||||
|
||||
| Fichier | Génération | Statut déclaré | IDs couverts |
|
||||
|---|---|---|---|
|
||||
| `docs/Assistant-V2/ASSISTANT-V2.md` (528 l.) | **V2** — 2026-10-08 | « Spécification (non implémentée) » | #191 → #201 |
|
||||
| `assistant-v21-phase-0-socle-sur.md` (279 l.) | **V2.1** — 2026-10-09 | « Prêt à implémenter » | #191,#192,#193,#206,#213 + amorce #212 |
|
||||
| `assistant-v21-phase-1-le-travail.md` (276 l.) | V2.1 | Prêt à implémenter | #202,#203,#204,#205 |
|
||||
| `assistant-v21-phase-2-temps-reel.md` | V2.1 | Prêt à implémenter | #200,#194,#199,#208,#214 |
|
||||
| `assistant-v21-phase-3-le-monde.md` | V2.1 | Prêt à implémenter | #195,#196,#197,#198,#207 |
|
||||
| `assistant-v21-phase-4-les-gens.md` | V2.1 | Prêt à implémenter | #211,#209,#201 |
|
||||
| `assistant-v21-phase-5-extension.md` (160 l.) | V2.1 | Cadre prêt | #210,#212 |
|
||||
|
||||
**Fait saillant n°1 — document parent manquant.** Les 5 phases V2.1 s'appuient toutes sur un `ASSISTANT-V2.1.md` (§2 P1–P16, §6.1, §8.2, §9.2/9.3, §10.2, §13.2, §15, §16, §19, §21). **Ce fichier n'existe nulle part dans le dépôt.** Conséquence directe : toutes les références croisées sont pendantes — notamment les schémas exacts des tables `tasks`/`inbox_items`/`facts`/`entities`/`agent_runs`, les règles de politique §15.2, et surtout **les définitions des golden tasks G1→G14** (§16.1) qui sont pourtant le critère d'acceptation central de chaque phase. On ne peut pas implémenter les phases « telles quelles » sans ce parent.
|
||||
|
||||
**Fait saillant n°2 — collision d'IDs.** Le Roadmap (v2.63.0) a déjà consommé ces ID :
|
||||
|
||||
- `#191` = « Chat — suppression de post, DM, link preview OG » (livré 2.60.0)
|
||||
- `#192` = « Chat — boîte auto-agrandissante, accusé ✓✓ » (livré 2.61.0)
|
||||
- `#194` = « Dossier personnel par utilisateur » (🔵 **en cours**, fiche `docs/features/user-home-194.md`)
|
||||
|
||||
Or V2.1 assigne #191 = purge Ollama, #192 = mémoire socle, #194 = consolidation nocturne. **Ce sont des collisions avec du code déjà publié**, ce qui viole la règle n°1 d'`AGENTS.md` (« ID stable, jamais réutilisé »).
|
||||
|
||||
**Fait saillant n°3 — plan non enregistré.** Aucune des 7 docs n'est référencée dans `ROADMAP.md`, dans la cartographie documentaire d'`AGENTS.md`, ni dans `docs/features/`. Le programme entier vit dans une bulle documentaire non branchée sur le processus de livraison.
|
||||
|
||||
---
|
||||
|
||||
## 2. Résumé de la solution
|
||||
|
||||
### Vision (4 piliers)
|
||||
Transformer ObsiGate d'un « portail de notes avec assistant » en **second cerveau conversationnel autonome** :
|
||||
1. **Il se souvient** — mémoire long terme structurée (faits, préférences, entités, décisions), consolidée la nuit, **auditable en markdown dans le vault** (anti-boîte-noire).
|
||||
2. **Il agit** — sur le vault **et** les services externes connectés (Gmail, Google Agenda, Teams, Discord, Notion, Drive…).
|
||||
3. **Il anticipe** — rappels conversationnels, briefing quotidien, détection de boucles ouvertes et d'engagements non tenus.
|
||||
4. **Il centralise** — ObsiGate devient le point d'entrée unique (desktop Tauri, MCP externe, PWA mobile → *ce même* cerveau).
|
||||
|
||||
### Principes directeurs
|
||||
- **V2 (P1–P9)** : autonomie du stack (1 conteneur, Ollama retiré) · zéro framework d'agent externe · un outil = un `@tool` typé · mémoire = markdown d'abord · humain dans la boucle · secrets jamais committés · i18n FR/EN · desktop Tauri offline-first · réutilisation de la sécurité existante.
|
||||
- **V2.1 (ajout majeur)** : insère **dès la phase 0** deux mécanismes qui rendent tout le reste délégable :
|
||||
- **Moteur de politiques** (#213) — décision **déterministe** `autorisé | approbation_run | approbation_renforcée | refusé` = f(risque × niveau d'autonomie × provenance × sensibilité × réversibilité). **Jamais de LLM dans le chemin d'autorisation.**
|
||||
- **Exécution fiable** (#206) — *« un run est l'unité de tout »* : plans avant écriture, approbation par run, `agent_runs`/`agent_steps`, post-conditions (`verify`), inverses (`inverse`), idempotence, reprise après crash, annulation (undo).
|
||||
|
||||
Le delta V2 → V2.1 est significatif et pertinent : la V2 donnait de la mémoire et des connecteurs **sans** le socle de sécurité/exécution. La V2.1 corrige cela en prerequisite. Mais la V2.1 introduit aussi ~2× plus de périmètre.
|
||||
|
||||
### Phasage et effort (source : docs)
|
||||
|
||||
| Phase | Thème | Tranches | Effort |
|
||||
|---|---|---|---|
|
||||
| **0** | Socle sûr (purge Ollama, mémoire, miroir, politiques, runs) | #191,#192,#193,#213,#206, amorce #212 | 15–19 j |
|
||||
| **1** | Le travail (tâches, Aujourd'hui, planification, capture) | #202,#203,#204,#205 | 14–18 j |
|
||||
| **2** | Le temps réel (agent programmé, consolidation, rappels, automations, rituels) | #200,#194,#199,#208,#214 | 14–19 j |
|
||||
| **3** | Le monde (connecteurs, sync, client MCP) | #195,#196,#197,#198,#207 | 17–23 j |
|
||||
| **4** | Les gens (personnes & engagements, voix, insights) | #211,#209,#201 | 9–13 j |
|
||||
| **5** | L'extension (packs A–G + éval continue) | #210,#212 | 3–5 j/chantier + continu |
|
||||
|
||||
**Total cœur = ≈ 69–92 jours** de développement, plus l'extension. C'est un **programme de plusieurs mois**, pas une feature.
|
||||
|
||||
---
|
||||
|
||||
## 3. Les composantes à mettre en place
|
||||
|
||||
### 3.1 Backend — nouveaux modules
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
L[agent/loop.py existant] --> P[agent/policies.py]
|
||||
L --> R[agent/runs.py]
|
||||
P --> REG[tools/registry.py étendu verify/inverse]
|
||||
M[memory/ store, entities, recall, compiler, mirror, consolidator] --> T1[tools/memory.py]
|
||||
C[connectors/ base, oauth, registry, sync, ical/caldav/imap, mcp_client] --> T2[tools/connectors.py]
|
||||
A[automations/ bus, engine, detectors] --> T3[tools/reminders.py]
|
||||
K[tasks/ model, store, rrule, planner] --> T4[tools/tasks.py + planner.py]
|
||||
I[inbox/store.py] --> T5[tools/inbox.py]
|
||||
G[people/ + voice/] --> T6[tools/people.py, commitment, voice]
|
||||
```
|
||||
|
||||
| Module | Rôle |
|
||||
|---|---|
|
||||
| `backend/memory/{store,entities,recall,compiler,mirror,consolidator}.py` | faits bi-temporels, entités/relations, recall RRF, compileur de contexte, miroir markdown, consolidation nocturne |
|
||||
| `backend/agent/policies.py` | moteur de politiques déterministe |
|
||||
| `backend/agent/runs.py` | `agent_runs`/`agent_steps`, plans, idempotence, reprise, undo |
|
||||
| `backend/connectors/{base,oauth,registry,sync,ical,caldav,imap,mcp_client}.py` | cadre connecteurs OAuth2+PKCE + replis zéro-OAuth + sync incrémentale + client MCP |
|
||||
| `backend/tasks/{model,store,rrule,planner}.py` | parseur de tâches markdown, index, RRULE, moteur de planification |
|
||||
| `backend/inbox/store.py`, `backend/automations/{bus,engine,detectors}.py` | capture, bus d'événements, automatisations, détecteurs d'insights |
|
||||
| `backend/tools/{memory,connectors,reminders,tasks,planner,inbox,people,voice}.py` | outils `@tool` |
|
||||
| `backend/routers/{memory,runs,delegations,today,tasks,plans,approvals,inbox,connectors,automations,reminders,commitments}.py` | API REST |
|
||||
|
||||
### 3.2 Stores de données (tous dans `data/`, JSON ou SQLite reconstructible)
|
||||
|
||||
- `data/memory.sqlite3` — `facts` (bi-temporel : `entity_id, valid_from, valid_to, recorded_at, sensitivity, provenance_trust`, `kind` étendu à `procedure`), `entities`, `relations`, `consolidation_runs`.
|
||||
- `data/assistant.sqlite3` — `agent_runs`, `agent_steps`, `delegations`, `approvals`, `inbox_items`, `commitments`, `plans`, `automation_runs`, `event_log`.
|
||||
- `data/tasks.sqlite3` — index des tâches (reconstructible).
|
||||
- `data/connectors_cache.sqlite3` — `sync_state`, `synced_objects` (extraits bornés, jamais le corps complet).
|
||||
- `data/connectors.json` (public) + `data/connector_secrets.json` (0600, chiffré `secret.key`).
|
||||
|
||||
### 3.3 Artefacts vault — `99_Assistant/`
|
||||
|
||||
`Profil.md` (balises `<!-- obsigate:managed:* -->`, re-parsées en faits `source='user'`), `Insights.md`, `Rappels.md`, `Personnes.md`, `Journal.md`, `Briefings/YYYY-MM-DD.md`, `Plans/YYYY-MM-DD.md`, `Automatisations/*.md`, `Revues/`.
|
||||
|
||||
### 3.4 Frontend (vanilla JS, zéro build — contrainte à respecter)
|
||||
|
||||
Vue **Aujourd'hui**, **file d'approbations unifiée**, **centre de contrôle** (runs, délégations, budgets, santé des index), page Configurations étendue **« Sources connectées »**, cartes mémoire/pending, panneau cloche rappels, vue Automatisations + dry-run, registre des packs. i18n FR **et** EN obligatoire.
|
||||
|
||||
### 3.5 Infra & config
|
||||
|
||||
- Retrait Ollama : `docker-compose.yml` + `.test-linux` + `.test-win` (service, volume `ollama_data`, `OLLAMA_BASE_URL/MODEL`, port 11434).
|
||||
- Variables : `OBSIGATE_MEMORY_*`, `OBSIGATE_CONSOLIDATION_*`, `OBSIGATE_TZ`, `OBSIGATE_BRIEFING_TIME`, `OBSIGATE_NOTIFY_DAILY_BUDGET`, `GOOGLE_CLIENT_ID/SECRET`, `AZURE_CLIENT_ID/SECRET`, `OBSIGATE_NOTION_API_KEY`, `OBSIGATE_DISCORD_BOT_TOKEN`, `OBSIGATE_RUN_MAX_*`.
|
||||
- Pas de nouvelle dépendance lourde nécessaire : `httpx` et `mcp` sont déjà présents.
|
||||
|
||||
---
|
||||
|
||||
## 4. État réel du code (vérifié, pas déduit)
|
||||
|
||||
| Élément cité par les specs | Réalité constatée |
|
||||
|---|---|
|
||||
| `backend/agent/loop.py` (`run_agent`) | ✅ existe (`DEFAULT_MAX_ITERATIONS=10`, `STOP_*`, propose/apply) |
|
||||
| `backend/tools/registry.py` (`@tool`, `ToolRisk`) | ✅ existe — mais **pas de `verify`/`inverse`/`data_classes`** (donc P0-T12 est bien un vrai travail) |
|
||||
| `backend/scheduler.py` (actions, schedules) | ✅ existe — actions `create_file`/`append_to_file`/`notify`, schedules `interval_hours`/`daily_time`/`once_at` |
|
||||
| `backend/notify.py` (`broadcast`) | ✅ existe |
|
||||
| `backend/mcp/server.py` (propose/apply) | ✅ existe |
|
||||
| `data/secret.key`, chiffrement, `0600` | ✅ pattern présent (`auth/jwt_handler.py`) |
|
||||
| **`backend/memory/`, `backend/connectors/`, `policies.py`, `runs.py`** | ❌ **inexistants** |
|
||||
| **`memory.sqlite3`, `assistant.sqlite3`, `agent_runs`** | ❌ **inexistants** |
|
||||
| Ollama dans les compose | ✅ **toujours présent** (les 3 files) |
|
||||
|
||||
Conclusion : les specs décrivent correctement le socle existant (bon signe), et tout ce qui est « nouveau » est effectivement à construire. Le statut « non implémenté » du doc V2 est exact.
|
||||
|
||||
---
|
||||
|
||||
## 5. Points forts de la conception
|
||||
|
||||
- **Réutilisation maximale de l'existant** : boucle agent, registre `@tool`, MCP, scheduler, notify, Web Push, recherche TF-IDF+sémantique — rien de tout cela n'est refait. La V2.1 branche proprement les nouvelles capacités.
|
||||
- **Anti-boîte-noire** : mémoire auditable en markdown, éditions humaines re-parsées comme faits `source='user'` confiance 1.0. C'est la vraie différenciation face à ChatGPT-Memory.
|
||||
- **Sécurité pensée tôt** : moteur de politiques déterministe, provenance/taint, whitelist des runs non interactifs, refus des envois externes sans relecture, traitement du contenu externe comme *données* (anti-injection), budgets durs.
|
||||
- **Coût maîtrisé** : consolidation déterministe avant LLM, cheap model dédié, budgets tokens, règle « lire le cache pendant que sa sync dort ».
|
||||
- **Dégradabilité** : chaque phase fonctionne sans les suivantes (la V2.1 insiste : phase 4 complète sans même un connecteur), connecteurs absents du registre si non installés (offline-first Tauri).
|
||||
- **DoD aligné sur le dépôt** : golden tasks, i18n FR/EN, fiches features, CHANGELOG/ROADMAP, ruff/mypy.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pistes d'amélioration
|
||||
|
||||
### 🔴 Bloquantes (à traiter avant d'écrire une ligne de code)
|
||||
|
||||
1. **Réécrire/écrire le document parent `ASSISTANT-V2.1.md`.** Sans lui, les schémas `tasks`/`inbox_items`/`facts`/`entities`/`agent_runs`, les règles de politique §15.2 et **les définitions des golden tasks G1→G14** n'existent pas. Options : (a) reconstituer le parent à partir des 5 phases + de la V2, ou (b) fusionner les phases en un document unique auto-suffisant. **Recommandé : (b)**, car maintenir 6 documents cohérents pour ~90 jours de travail va dériver.
|
||||
2. **Renuméroter tout le bloc d'IDs.** #191, #192, #194 sont déjà pris (chat, user-home). Proposer un bloc contigu réservé (ex. `#215→#238`) et le figer, puis remapper V2 et V2.1. Décider aussi de la convention unique : les docs mélangent `#NNN` (roadmap) et `P0-Tnn`/`P2-Tnn` (tickets) sans mapping explicite — à trancher et documenter.
|
||||
3. **Enregistrer le programme dans le Roadmap et la cartographie documentaire.** Créer les entrées `ROADMAP.md` (statut 🔵/⚪) et les fiches `docs/features/`, ajouter `docs/Assistant-V2/` à la table d'`AGENTS.md`. Aujourd'hui le programme viole la règle « ID stable avant de coder ».
|
||||
4. **Décider du sort de la doc V2.** La V2 **contredit** la V2.1 : V2 dit « `gmail.send` non exposé v1 », V2.1 phase 3 l'expose en DANGEROUS. V2 propose une table `facts` sans bi-temporalité/entités que V2.1 remplace. Garder les deux crée un piège pour l'implémenteur. **Recommandé : marquer `ASSISTANT-V2.md` comme « superseded »** et pointer vers la V2.1.
|
||||
|
||||
### 🟡 Importantes (qualité d'implémentation)
|
||||
|
||||
5. **Trancher le séquençage par valeur, pas par phases.** Les phases sont bien dessinées, mais ~90 j avant le premier bénéfice visible est risqué. **Recommandé : livrer d'abord un « vertical slice » de la seule phase 0** (purge Ollama + mémoire + miroir + runs + politiques) — c'est là que vivent 80 % du risque technique et 100 % des prérequis de sécurité. Ne pas commencer les phases 1–5 avant d'avoir prouvé la phase 0.
|
||||
6. **Concurrence SQLite / writer unique.** Les specs ajoutent 4–5 bases manipulées par la boucle agent, le watcher, le scheduler (tick 60 s) et les handlers HTTP, dans **un seul process**. À cadrer explicitement : mode WAL, discipline « un seul écrivain par store », verrous, et reprise. Ce n'est évoqué nulle part et c'est une source classique de corruption.
|
||||
7. **Qualité de l'extraction de faits = le composant le plus risqué.** C'est le seul endroit où « le modèle décide ». Recommander : (a) un **dry-run de consolidation** obligatoire avant activation (existe pour automations, à généraliser), (b) validation Pydantic stricte + rejet des valeurs non sourcées, (c) métrique « faits rejetés/acceptés » dès la phase 0, (d) un mode « extracteur désactivé » par défaut la première semaine.
|
||||
8. **Fuites multi-utilisateurs via le miroir.** La mémoire est `user_id` par utilisateur, mais le miroir vit dans **un vault** `99_Assistant/`. Si le vault est partagé, `Profil.md`/`Personnes.md` exposent la vie privée d'un user à un autre. À trancher : vault mémoire **par utilisateur** (le plus sûr, cohérent avec `home-<user>`), ou sections par user + ACL. À documenter et tester.
|
||||
9. **Chiffrement au repos et `0600` sous Windows.** Le pattern `data/connector_secrets.json` en `0600` est POSIX. `os.chmod` ne pose pas d'ACL sous Windows, alors que le desktop Tauri est multi-OS. Prévoir une stratégie (ACL via `pywin32`, ou DPAPI, ou au minimum documenter la limite). #172 est cité comme dépendance mais pas défini ici.
|
||||
10. **Golden tasks non branchées au CI.** `AGENTS.md` impose les 5 jobs CI (`lint/test/security/build/e2e`). Les golden tasks (`tests/golden/`) nécessitent un job dédié + des **simulateurs HTTP par fournisseur** (prévus en phase 3). À cadrer dès la phase 0 (harnais) pour ne pas découvrir le problème en phase 3. La phase 5 **oublie complètement l'E2E** alors qu'elle crée une UI (registre des packs) — non conforme à la DoD du dépôt.
|
||||
11. **Friction OAuth.** Créer des apps Google Cloud / Entra ID est le principal frein d'installation. Bonne idée des specs : les **replis zéro-OAuth (iCal/CalDAV/IMAP)**. Recommander d'en faire le **chemin par défaut** et OAuth une option avancée, plutôt que l'inverse — cela dérisque toute la phase 3.
|
||||
|
||||
### 🟢 Confort / simplifications
|
||||
|
||||
12. **Mutualiser les stores.** `tasks.sqlite3`, `connectors_cache.sqlite3`, `memory.sqlite3` et `assistant.sqlite3` pourraient être **un seul** `assistant.sqlite3` (WAL) : moins de fichiers, moins de cycles de vie, un seul point de backup. Le « reconstructible séparément » se gère par table. À évaluer.
|
||||
13. **FTS5 natif** pour l'index des tâches et la recherche mémoire, plutôt qu'un index plein-texte maison — SQLite le fournit déjà.
|
||||
14. **Unifier rappel = tâche enrichie** (déjà proposé en V2.1 §7.2) : excellent, à confirmer comme décision figée pour éviter toute tentation de second store.
|
||||
15. **Instrumenter les métriques #212 dès la phase 0** (budget de contexte mémoire, faits acceptés/rejetés, temps de décision quotidien). Les specs le promettent ; en faire un vrai tableau de bord tôt évite les optimisations à l'aveugle.
|
||||
16. **Formaliser les critères de sortie de chaque phase** (les phases 0–4 en ont, la 5 non) et une **décision unique** sur les « décisions ouvertes » du parent (activation de l'envoi, fournisseur de transcription, gestionnaire de tâches externe) — ces 3 décisions conditionnent plusieurs tickets et ne sont pas tranchées.
|
||||
17. **Points ambigus relevés par phase** (à lever dans le parent) : schéma `consolidation_runs` jamais défini (phase 2) ; format de `allowed_extra_tools` et de `ctx: rappel` ; emplacement du réglage `candidats connecteurs` (`off|candidats`) ; modalités MCP (stdio vs HTTP) et algorithme de plafonnement de risque ; dédup multi-calendriers (tolérance fuseaux/récurrences) ; granularité Notion→index ; format de persistance des packs (`data/` ?).
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommandation de prochaines étapes
|
||||
|
||||
1. **Décider du plan de renumérotation** et réserver un bloc d'IDs contigu.
|
||||
2. **Consolider la doc** : écrire le parent V2.1 (ou fusionner), marquer la V2 comme superseded, lever les ~15 ambiguïtés ci‑dessus.
|
||||
3. **Enregistrer** le programme dans `ROADMAP.md` + `docs/features/` + `AGENTS.md`.
|
||||
4. **Commencer par le vertical slice de la phase 0** (purge Ollama + mémoire + miroir + runs + politiques + harnais golden), et **ne pas** démarrer les phases 1–5 avant que la phase 0 soit verte en CI.
|
||||
|
||||
---
|
||||
|
||||
Souhaites-tu que je rédige :
|
||||
- le **document de synthèse consolidé** (parent V2.1 auto-suffisant) à partir des 5 phases et de la V2, ou
|
||||
- une **proposition de renumérotation** concrète (mapping `ancien ID → nouveau ID`) prête à coller dans le Roadmap ?
|
||||
|
||||
Je peux aussi produire un `docs/features/assistant-v2-index.md` qui sert de plan-maître et référence les 7 documents actuellement orphelins.
|
||||
@@ -0,0 +1,278 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 0 : Socle sûr (purge, mémoire, politiques, exécution fiable)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **0 sur 5** — fondation de toute la solution |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §2 (P1–P16), §5, §9, §10, §15, §16, §19 |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Prêt à implémenter |
|
||||
| Tranches | **#191** (purge Ollama), **#192** (mémoire socle amendée), **#193** (miroir markdown), **#213** (politiques & taint), **#206** (exécution fiable), amorce de **#212** (harnais golden tasks) |
|
||||
| Effort estimé | 15–19 jours (dont 1–2 j d'amorce #212 ; le solde de #212 est continu, phases 1–5) |
|
||||
| Prérequis | ObsiGate actuel : boucle agent `backend/agent/loop.py`, registre `backend/tools/registry.py`, scheduler #170, notifications #168, Web Push #67, recherche #70 — tels que décrits dans l'inventaire V2.1 §4 |
|
||||
| Dépendances | Aucune (première phase) |
|
||||
| Débloque | Phase 1 (tâches et planification s'appuient sur les runs et la mémoire), et toutes les écritures externes ultérieures (#213/#206 sont le prérequis de sécurité des phases 3–4) |
|
||||
|
||||
> **Résultat visible à la fin de cette phase.** Le conteneur Ollama a disparu. L'assistant possède une mémoire long terme : il enregistre des faits (avec historique bi-temporel), des entités (personnes, projets, lieux) et des procédures, les montre dans `99_Assistant/Profil.md` — éditable à la main, les éditions étant reprises comme des faits de confiance maximale — et les injecte dans chaque conversation via un compileur de contexte dont chaque section cite son origine. Surtout, **tout ce que l'assistant exécute devient un run persistant** : plan affiché avant les écritures, approbation par run, étapes journalisées avec preuves de post-condition, reprise sans doublon après interruption, annulation quand l'inverse existe. Et un moteur de politiques déterministe décide ce qui est autorisé, approuvé ou refusé — jamais le modèle.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Périmètre](#2-périmètre)
|
||||
3. [État d'entrée — ce qui existe](#3-état-dentrée--ce-qui-existe)
|
||||
4. [Modèle de données](#4-modèle-de-données)
|
||||
5. [Tickets de travail](#5-tickets-de-travail)
|
||||
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
|
||||
7. [Comportements détaillés](#7-comportements-détaillés)
|
||||
8. [Tests et golden tasks](#8-tests-et-golden-tasks)
|
||||
9. [Critères d'acceptation de la phase](#9-critères-dacceptation-de-la-phase)
|
||||
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
|
||||
11. [Ordre d'exécution](#11-ordre-dexécution)
|
||||
12. [Définition de « terminé »](#12-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **on ne donne pas plus de pouvoir à l'assistant avant d'avoir rendu son pouvoir actuel sûr, traçable et réversible.** La V2 livrait la mémoire puis les connecteurs ; la V2.1 intercale, dès la phase 0, les deux mécanismes qui rendent toutes les phases suivantes délégables : le **moteur de politiques** (#213 — qui a le droit de faire quoi, avec quelles données, venues d'où) et l'**exécution fiable** (#206 — plan, preuves, reprise, annulation).
|
||||
|
||||
Corollaires qui guident chaque ticket :
|
||||
|
||||
- **La mémoire est un index du vault, pas un silo.** Faits confirmés → miroir markdown ; la base ne détient en propre que les états de travail (`pending`, historique de runs).
|
||||
- **Un run est l'unité de tout.** Conversation, tâche planifiée (phase 2), automatisation (phase 2), MCP : à la fin de la phase, toute exécution d'outils par un agent produit une ligne `agent_runs` et ses `agent_steps`. Les journaux épars d'aujourd'hui convergent là.
|
||||
- **La politique est déterministe.** Risque du registre × niveau d'autonomie × provenance × sensibilité × réversibilité → décision. Aucun appel LLM dans le chemin d'autorisation, jamais.
|
||||
- **Pas de succès sans preuve.** Un run n'est `succeeded` que si ses post-conditions vérifiables sont vraies (§7.3).
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### 2.1 Inclus
|
||||
|
||||
- #191 : retrait du service/volume Ollama des trois compose, défauts provider nettoyés, notes de migration (repris tel quel de la V2 §9).
|
||||
- #192 : `backend/memory/` — store SQLite amendé (faits bi-temporels, sensibilité, provenance, procédures), entités et relations, recall et **compileur de contexte** (§9.4 du parent), outils `memory.*`, `entity_resolve`, `procedure_get`, API mémoire, cartes de validation des faits `pending` (forme simple — la file unifiée arrive en phase 1, #203).
|
||||
- #193 : `mirror.py`, dossier `99_Assistant/`, `Profil.md` à balises gérées avec re-parse des éditions humaines, `Journal.md` des consolidations, `Personnes.md` en **coquille** (le peuplement riche est en phase 4, #211).
|
||||
- #213 : provenance/taint sur tous les contenus entrant dans un contexte ou un run, moteur de politiques `backend/agent/policies.py` évalué avant chaque appel d'outil, règles du §15.2 du parent, journal des refus, tests d'injection.
|
||||
- #206 : plans d'exécution, `agent_runs`/`agent_steps` dans `data/assistant.sqlite3`, idempotence et reprise, post-conditions déclarées dans le registre (`verify`), inverses (`inverse`), niveaux d'autonomie 0–3 et délégations bornées datées, API runs + progression SSE.
|
||||
- #212 (amorce) : harnais de golden tasks (`tests/golden/`, fixtures de vault, connecteurs simulés) et les scénarios G3, G6, G7 de la phase.
|
||||
|
||||
### 2.2 Exclu (phases ultérieures)
|
||||
|
||||
- La **consolidation nocturne** (#194) : la phase 0 livre le store, le recall et la création de faits en conversation ; l'extraction automatique nocturne arrive en phase 2, quand `agent_prompt` (#200) existe.
|
||||
- La **vue Aujourd'hui**, la file d'approbations unifiée et le centre de contrôle complet (#203, phase 1) : la phase 0 livre les cartes mémoire simples et une liste de runs basique dans les Configurations.
|
||||
- Les **tâches** (#202, phase 1) et tout connecteur (#195+, phase 3).
|
||||
- La politique « contenu connecteur » est livrée comme **mécanisme** (provenances `connector:<id>` reconnues) ; son premier usage réel est en phase 3.
|
||||
|
||||
## 3. État d'entrée — ce qui existe
|
||||
|
||||
| Élément | État | Rôle en phase 0 |
|
||||
|---|---|---|
|
||||
| Boucle agent + tool calling natif | ✅ `backend/agent/loop.py`, `backend/ai_chat.py` | Moteur des runs ; étendu, pas remplacé (P2) |
|
||||
| Registre `@tool` (risque/scope/confirmation/audit/rate-limit) | ✅ `backend/tools/registry.py` | Reçoit les champs `verify` et la provenance ; la politique s'y branche |
|
||||
| Historique de conversation persistant | ✅ #95, `backend/ai_history.py` | Mémoire épisodique brute (source future de #194) |
|
||||
| Recherche TF-IDF + sémantique RRF | ✅ #70 | Recall : fusion faits + recherche |
|
||||
| Cache SQLite (patron) | ✅ `backend/tools/webcache.py` | Modèle des stores SQLite de la phase |
|
||||
| Audit + redaction des secrets | ✅ `backend/audit.py`, `secret_redactor.py` | Journal des runs et des refus ; args toujours redactés |
|
||||
| Providers LLM multi-fournisseurs | ✅ `backend/ai.py` | Routage par type de job : amorce posée (§16.3 du parent), table complète en phases suivantes |
|
||||
| Ollama comme conteneur | ✅ présent | **Retiré** par #191 ; le provider `ollama` reste une option de configuration (desktop local) |
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
Repris du parent : `facts` amendée (§9.2 — `entity_id`, `valid_from`, `valid_to`, `recorded_at`, `sensitivity`, `provenance_trust`, `kind` étendu à `procedure`), `entities` et `relations` (§9.3), `consolidation_runs` (créée dès cette phase, alimentée en phase 2), `agent_runs` et `agent_steps` (§10.2), auxquelles s'ajoute la table des délégations :
|
||||
|
||||
```sql
|
||||
CREATE TABLE delegations (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
domain TEXT NOT NULL, -- 'vault'|'tasks'|'email'|'calendar'|…
|
||||
level INTEGER NOT NULL, -- niveau accordé (0–3, §10.4 du parent)
|
||||
scope_json TEXT, -- périmètre : chemins, connecteur, budgets
|
||||
granted_at TEXT NOT NULL, review_at TEXT NOT NULL, -- défaut +90 j
|
||||
status TEXT NOT NULL DEFAULT 'active' -- active|expired|revoked
|
||||
);
|
||||
```
|
||||
|
||||
Stores : `data/memory.sqlite3` (faits, entités, relations, runs de consolidation) et `data/assistant.sqlite3` (runs, steps, délégations ; recevra `inbox_items`, `commitments`, `approvals` en phases 1 et 4 — le fichier est créé ici, les tables arrivent avec leurs tranches).
|
||||
|
||||
Règles transverses : toute table porte `user_id` ; aucun secret ni contenu brut de connecteur dans ces bases ; les args d'étapes sont stockés **redactés** ; les stores sont reconstructibles ou journalisés (jamais de contenu de Bruno irremplaçable hors du vault — P4).
|
||||
|
||||
## 5. Tickets de travail
|
||||
|
||||
*Convention : chaque ticket est indépendamment testable ; « Fichiers » donne les points d'ancrage d'après la V2.1 — à confirmer contre le code réel au démarrage du ticket.*
|
||||
|
||||
### Purge Ollama — #191
|
||||
|
||||
**P0-T01 — Retrait du conteneur Ollama.**
|
||||
Fichiers : `docker-compose.yml`, `docker-compose.test-linux.yml`, `docker-compose.test-win.yml`, docs d'installation.
|
||||
Travail : supprimer service, volume `ollama_data`, variables `OLLAMA_BASE_URL`/`OLLAMA_MODEL` et mentions du port 11434 ; procédure de suppression du volume documentée dans le CHANGELOG.
|
||||
Acceptation : `docker compose config` ne connaît plus Ollama ; l'instance démarre et l'agent fonctionne sur deepseek/openrouter/gemini.
|
||||
|
||||
**P0-T02 — Défauts provider nettoyés.**
|
||||
Fichiers : `backend/ai.py`, `model_capabilities.py`, tests d'isolation.
|
||||
Travail : conserver le provider `ollama` comme option explicite (desktop), retirer tout défaut pointant vers le réseau interne ; le mode par défaut de la mémoire fonctionne sans embeddings (TF-IDF + RRF + faits).
|
||||
Acceptation : aucun test ni défaut ne référence `http://ollama:11434` ; la recherche sémantique se dégrade proprement sans sentence-transformers.
|
||||
|
||||
### Mémoire socle — #192
|
||||
|
||||
**P0-T03 — Store mémoire : faits amendés, entités, relations.**
|
||||
Fichiers : nouveau `backend/memory/store.py` (patron `webcache.py`), création/ouverture de `data/memory.sqlite3`.
|
||||
Travail : DDL du §4 ; CRUD faits avec clôture bi-temporelle (supersede = `valid_to`, jamais d'écrasement) ; planchers de confiance V2 (< 0,7 → `pending`) ; dossiers sensibles configurables → `pending` + `sensitivity='sensitive'` ; renforcement `last_seen_at` et decay par `kind` (les `procedure` et `decision` ne décroissent pas).
|
||||
Acceptation : G7 passe (changement de dentiste : ancien fait clos, nouveau actif, requête historique datée correctement répondue) ; aucun fait `sensitive` créé directement en `confirmed`.
|
||||
|
||||
**P0-T04 — Entités et résolution de noms.**
|
||||
Fichiers : `backend/memory/entities.py`.
|
||||
Travail : CRUD entités (person/place/project/org/topic, alias) ; `resolve(name)` → candidates scorées ; **ambiguïté = pas de fusion** (renvoyer les candidates, la décision est une carte, phase 2 pour la consolidation) ; relations bi-temporelles.
|
||||
Acceptation : deux « Marie » distinctes coexistent sans fusion ; une relation fermée n'apparaît plus dans le recall actuel mais reste interrogeable à date.
|
||||
|
||||
**P0-T05 — Recall et compileur de contexte.**
|
||||
Fichiers : `backend/memory/recall.py`, `backend/memory/compiler.py`.
|
||||
Travail : score explicite et journalisé (RRF × confiance × fraîcheur × boost entité, parent §9.4) ; assemblage du bloc `<assistant-context>` à budget adaptatif (défaut 2 500 tokens) avec sections profil/procédures, entités du sujet, faits liés, engagements (vide cette phase), dernier briefing (absent cette phase — section omise proprement) ; chaque section cite son origine ; le bloc est visible dans les étapes du panneau avec le détail du score au survol.
|
||||
Acceptation : un fait sous le seuil n'entre pas dans le bloc ; le même bloc est reproduit à l'identique pour la même requête et le même état (déterminisme de structure) ; aucun contenu `sensitivity='sensitive'` dans un contexte destiné à un outil externe.
|
||||
|
||||
**P0-T06 — Outils mémoire et API.**
|
||||
Fichiers : `backend/tools/memory.py`, `backend/routers/memory.py`.
|
||||
Travail : les 5 outils V2 (`memory_recall`, `memory_save`, `memory_update`, `memory_forget`, `profile_get`) + `entity_resolve` (READ) et `procedure_get` (READ) ; `memory_save` passe par la politique (approbation selon niveau, défaut 2) ; endpoints CRUD faits (liste/filtre statut, validation `pending`, édition, oubli) et export JSON complet du domaine mémoire.
|
||||
Acceptation : un fait proposé en chat apparaît en carte, validé il est au miroir (T08) et au recall suivant ; `memory_forget` le retire du recall et du miroir, avec trace d'audit.
|
||||
|
||||
### Miroir markdown — #193
|
||||
|
||||
**P0-T07 — Miroir `Profil.md` à balises gérées.**
|
||||
Fichiers : `backend/memory/mirror.py`.
|
||||
Travail : création de `99_Assistant/` dans le vault mémoire (`OBSIGATE_MEMORY_VAULT`, mode multi-utilisateur du §5.3 du parent) ; rendu des sections gérées (préférences, procédures, objectifs connus) ; hash de section stocké en base ; écritures idempotentes (rendu deux fois = fichier identique).
|
||||
Acceptation : supprimer un fait validé régénère le miroir sans toucher au hors-balises ; deux rendus consécutifs produisent un diff vide.
|
||||
|
||||
**P0-T08 — Re-parse des éditions humaines.**
|
||||
Travail : avant chaque écriture, comparer le hash stocké au contenu courant ; une section modifiée à la main est re-parsée en faits `source='user'`, confiance 1.0, `confirmed`, puis régénérée ; une suppression dans le miroir = fait `rejected` en base ; le hors-balises n'est jamais touché.
|
||||
Acceptation : éditer `Profil.md` à la main (ajout + suppression) puis déclencher un rendu donne des faits conformes aux éditions, sans perte du texte libre hors balises.
|
||||
|
||||
**P0-T09 — `Personnes.md` (coquille) et `Journal.md`.**
|
||||
Travail : `Personnes.md` rendu depuis les entités personnes (nom, type de relation, dernière interaction connue) — en lecture structurée simple cette phase ; `Journal.md` alimenté par les événements mémoire (faits ajoutés/validés/rejetés, rendus du miroir) en lecture seule.
|
||||
Acceptation : une entité personne créée en T04 apparaît au miroir ; le journal trace chaque validation de carte.
|
||||
|
||||
### Politiques & taint — #213
|
||||
|
||||
**P0-T10 — Provenance de bout en bout.**
|
||||
Fichiers : `backend/agent/loop.py`, `backend/tools/registry.py`, `backend/ai_chat.py`.
|
||||
Travail : chaque contenu entrant dans un contexte ou un résultat d'outil reçoit une provenance (`user`, `vault`, `connector:<id>`, `web`, `mcp:<serveur>`, `automation`) portée dans les messages et les steps ; les résultats d'outils sont sanitisés (redaction existante) et étiquetés avant retour au modèle ; les prompts système distinguent explicitement blocs « instructions » et blocs « données ».
|
||||
Acceptation : un test d'intégration montre la provenance présente sur chaque résultat d'outil d'un run ; aucun résultat n'est injecté sans étiquette.
|
||||
|
||||
**P0-T11 — Moteur de politiques.**
|
||||
Fichiers : nouveau `backend/agent/policies.py` ; branchement dans le registre (évaluation **avant** chaque appel d'outil).
|
||||
Travail : décision déterministe `autorisé | approbation_run | approbation_renforcée | refusé` = f(risque registre, niveau d'autonomie du domaine, provenance, sensibilité des données, réversibilité) ; règles du parent §15.2 (une instruction de contenu non-`user` ne crée/lève aucune approbation ; action externe justifiée uniquement par un contenu non-`user` → refus + carte ; données `private|sensitive` interdites vers web/connecteurs en écriture/MCP externe) ; journal des refus avec motif, lisible dans l'audit.
|
||||
Acceptation : G3 passe (courriel piégé simulé → refus, carte à Bruno, zéro appel d'envoi) ; la décision est couverte par des tests unitaires exhaustifs sur la matrice des facteurs (aucun LLM dans le chemin).
|
||||
|
||||
**P0-T12 — Registre étendu : `verify`, inverses, sensibilité des paramètres.**
|
||||
Fichiers : `backend/tools/registry.py`, outils existants (annotations).
|
||||
Travail : champs optionnels du décorateur `@tool` : `verify` (post-condition vérifiable), `inverse` (constructeur d'inverse ou `None` explicite), `data_classes` (classes de données acceptées en entrée) ; annoter les outils vault et documents existants ; un outil d'écriture sans `verify` déclaré est listé dans un rapport de couverture (pas bloquant cette phase, bloquant pour les outils externes des phases 3+).
|
||||
Acceptation : le rapport de couverture existe et est revu ; `create_file`/`edit_file` déclarent leur post-condition et leur inverse (backup).
|
||||
|
||||
### Exécution fiable — #206
|
||||
|
||||
**P0-T13 — Stores de runs.**
|
||||
Fichiers : `backend/agent/runs.py`, `data/assistant.sqlite3`.
|
||||
Travail : DDL `agent_runs`/`agent_steps` du parent §10.2 ; cycle de vie complet des statuts ; écriture des steps au fil de l'exécution par la boucle existante (toute exécution d'outils crée ou rattache un run, `origin` renseignée) ; plafonds : itérations et budget tokens existants + durée murale et nombre d'écritures (`OBSIGATE_RUN_MAX_WALL_SECONDS`, `OBSIGATE_RUN_MAX_WRITES`).
|
||||
Acceptation : tuer le processus pendant un run laisse un run `running` orphelin détectable au redémarrage (marqué `paused` reprenable, jamais `succeeded`).
|
||||
|
||||
**P0-T14 — Plans d'exécution et approbation par run.**
|
||||
Travail : pour toute demande ≥ 2 écritures ou 1 outil DANGEROUS, génération d'un plan (étapes : outil, args résumés, effet attendu, post-condition, inverse) persisté dans `plan_json` ; affichage dans le panneau avant la première écriture ; validation en un clic = approbation du run entier (pas par étape) ; un plan refusé n'exécute rien et le run passe `cancelled` avec motif.
|
||||
Acceptation : une demande multi-écritures ne produit aucun effet avant validation ; le plan affiché correspond exactement aux steps exécutés ensuite.
|
||||
|
||||
**P0-T15 — Idempotence et reprise.**
|
||||
Travail : `idempotency_key` par step d'écriture (hash run+seq+args) respectée par les outils de création (vérification d'existence par clé en métadonnée) ; reprise d'un run interrompu à la première step non `done`, re-vérification des post-conditions incertaines, steps WRITE incertaines → `awaiting_approval` plutôt que rejeu aveugle.
|
||||
Acceptation : G6 passe (run tué à mi-parcours, repris : aucun doublon créé, état final vérifié).
|
||||
|
||||
**P0-T16 — Vérification et annulation.**
|
||||
Travail : exécution des `verify` après chaque step d'écriture ; statut final `succeeded` seulement si toutes les post-conditions vérifiables sont vraies, sinon `partially_succeeded` détaillé ; « Annuler ce run » exécute les inverses sûrs en ordre décroissant comme un run lié ; absence d'inverse affichée comme telle (« non annulable »).
|
||||
Acceptation : un outil dont la post-condition échoue (fixture) produit `partially_succeeded`, jamais `succeeded` ; l'annulation d'un run de création de fichiers restaure l'état initial via les backups.
|
||||
|
||||
**P0-T17 — Niveaux d'autonomie et délégations.**
|
||||
Fichiers : réglages Configurations, `delegations` (§4).
|
||||
Travail : niveaux 0–3 par domaine (défauts : vault = 2, tout domaine externe = 0/1 selon existence) ; création de délégation bornée (périmètre + budget + `review_at` +90 j) depuis une approbation (« toujours autoriser ce périmètre ») ou les Configurations ; expiration automatique → retour au niveau inférieur ; **aucun niveau ne couvre** paiements, envois externes sans relecture, suppressions définitives, changements de sécurité (codé en politique, pas en réglage).
|
||||
Acceptation : une délégation expirée cesse de s'appliquer sans intervention ; tenter de déléguer un envoi externe est refusé par l'UI et par l'API.
|
||||
|
||||
**P0-T18 — API runs et progression.**
|
||||
Fichiers : `backend/routers/runs.py`.
|
||||
Travail : `GET /api/runs` (filtres statut/origine), `GET /api/runs/{id}` (plan, steps, preuves), actions `pause`/`resume`/`cancel`/`undo` ; progression en SSE sur le flux existant ; liste de runs basique dans les Configurations (le centre de contrôle complet est en phase 1).
|
||||
Acceptation : un run en cours est observable en direct ; pause puis reprise depuis l'API aboutit au même résultat qu'une exécution continue.
|
||||
|
||||
### Évaluation — amorce #212
|
||||
|
||||
**P0-T19 — Harnais golden tasks.**
|
||||
Fichiers : `tests/golden/` (fixtures de vault, connecteurs simulés, harnais d'exécution headless).
|
||||
Travail : infrastructure de scénarios rejouables en CI (état initial → demande → assertions sur les stores, le vault et les runs) ; livrer G3 (injection), G6 (reprise), G7 (bi-temporalité) ; gabarit documenté pour les phases suivantes (chaque phase ajoute les siens).
|
||||
Acceptation : les trois scénarios tournent en CI et échouent si le comportement régresse (démontré par mutation volontaire en développement).
|
||||
|
||||
### Finitions
|
||||
|
||||
**P0-T20 — i18n, docs, guides.**
|
||||
Travail : clés FR/EN pour tout libellé nouveau (`ai.step.*` mémoire/runs/politiques, cartes, réglages d'autonomie) ; fiches `docs/features/` pour #191, #192/#193, #213, #206 ; guide utilisateur « Mémoire et contrôle de l'assistant » ; CHANGELOG + ROADMAP.
|
||||
Acceptation : DoD du §12 vérifiée point par point.
|
||||
|
||||
## 6. API livrée par la phase
|
||||
|
||||
| Route | Rôle | Ticket |
|
||||
|---|---|---|
|
||||
| `GET/POST/PATCH /api/memory/facts` | CRUD faits, validation des `pending` | P0-T06 |
|
||||
| `GET /api/memory/entities` · `/api/memory/export` | Entités ; export du domaine mémoire | P0-T04/T06 |
|
||||
| `GET /api/runs` · `GET /api/runs/{id}` | Liste et détail (plan, steps, preuves) | P0-T18 |
|
||||
| `POST /api/runs/{id}/pause|resume|cancel|undo` | Pilotage d'un run | P0-T18 |
|
||||
| `GET/POST/DELETE /api/delegations` | Délégations bornées par domaine | P0-T17 |
|
||||
|
||||
Toutes suivent l'authentification et les permissions par vault existantes ; les nouveaux outils `@tool` sont exposés au MCP automatiquement (scopes IN_APP+MCP, propose/apply déjà gérés).
|
||||
|
||||
## 7. Comportements détaillés
|
||||
|
||||
### 7.1 Qui peut faire quoi
|
||||
|
||||
La mémoire, les runs et les délégations sont **par utilisateur** (`user_id` partout, parent §5.3). Un administrateur ne voit pas la mémoire d'un autre utilisateur ; l'audit montre les refus de politique sans le contenu sensible qui les a motivés.
|
||||
|
||||
### 7.2 Fait `pending` : durée de vie
|
||||
|
||||
Un fait `pending` reste en carte jusqu'à action (valider/éditer/oublier) — pas d'expiration silencieuse. Au-delà de 30 jours, il est regroupé dans un rappel hebdomadaire unique (pas une notification par fait ; le budget d'interruption P15 s'applique dès cette phase aux cartes).
|
||||
|
||||
### 7.3 Post-conditions : la règle du succès
|
||||
|
||||
`verify` absent ≠ succès présumé : le step est marqué `verified = NULL` et le run ne peut prétendre qu'à `succeeded` si **aucun** step d'écriture n'est simultanément `done` et non vérifié *alors qu'un `verify` existait*. Le rapport de couverture (P0-T12) rend la dette visible au lieu de la cacher.
|
||||
|
||||
### 7.4 Reprise après crash du conteneur
|
||||
|
||||
Au démarrage, tout run `running` sans heartbeat récent passe `paused` avec le motif `interrupted` ; la reprise est manuelle (API ou panneau) cette phase — la reprise automatique des runs `origin='scheduled'` arrive avec #200 en phase 2.
|
||||
|
||||
## 8. Tests et golden tasks
|
||||
|
||||
- Unitaires : store mémoire (clôture bi-temporelle, decay, planchers), résolution d'entités (ambiguïté), moteur de politiques (matrice complète des facteurs), idempotence (rejouer une step ne crée pas de doublon), miroir (idempotence du rendu, re-parse).
|
||||
- Intégration : run complet avec plan → approbation → exécution → vérification ; kill en cours de run → reprise ; édition manuelle de `Profil.md` → faits `source='user'`.
|
||||
- Golden tasks de la phase : **G3** (injection), **G6** (reprise), **G7** (bi-temporalité) — définitions au parent §16.1.
|
||||
- Non-régression : l'assistant actuel (sans mémoire activée) se comporte à l'identique ; `OBSIGATE_MEMORY_ENABLED=false` éteint la phase proprement.
|
||||
|
||||
## 9. Critères d'acceptation de la phase
|
||||
|
||||
1. Le conteneur Ollama n'existe plus et l'assistant fonctionne sur les providers API.
|
||||
2. Un fait dit en conversation, validé par carte, est dans `Profil.md`, au recall de la conversation suivante, et son remplacement ultérieur conserve l'historique interrogeable à date.
|
||||
3. Éditer `Profil.md` à la main modifie la mémoire (source `user`, confiance 1.0) ; rien hors balises n'est jamais écrasé.
|
||||
4. Un run multi-écritures affiche son plan, n'exécute rien avant approbation, prouve chaque post-condition, et un run tué reprend sans doublon.
|
||||
5. Le scénario d'injection G3 est refusé par la politique, journalisé, et présenté à Bruno en carte.
|
||||
6. Les golden tasks G3/G6/G7 sont verts en CI.
|
||||
|
||||
## 10. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| Le compileur de contexte consomme le budget du modèle en silence | Le bloc est plafonné, son détail est visible (P0-T05) ; la métrique « tokens de contexte mémoire / conversation » est journalisée dès cette phase (alimente #212) |
|
||||
| Approbation par run trop grossière (tout ou rien) | Le plan affiche chaque step ; un step peut être **exclu** du plan avant validation (case par step) — l'approbation reste unique, le périmètre est ajustable |
|
||||
| Le miroir et une édition collaborative (Yjs) se marchent dessus | Le miroir écrit via le même chemin de sauvegarde que l'éditeur (backup préalable) et compare les hashs avant/après (règle durcie en phase 2 pour la consolidation) |
|
||||
| Dérive du périmètre : vouloir les tâches dès la phase 0 | Les tâches sont la phase 1 ; la phase 0 ne livre que les fondations qu'elles consommeront (runs, politique, mémoire) |
|
||||
|
||||
## 11. Ordre d'exécution
|
||||
|
||||
```
|
||||
P0-T01 → T02 (#191, indépendant, à faire en premier)
|
||||
P0-T03 → T04 → T05 → T06 (#192)
|
||||
P0-T07 → T08 → T09 (#193, après T03/T06)
|
||||
P0-T10 → T11 → T12 (#213, T10 tôt : la provenance sert les runs)
|
||||
P0-T13 → T14 → T15 → T16 → T17 → T18 (#206, après T11)
|
||||
P0-T19 (harnais tôt, scénarios au fil des tickets) → P0-T20 (clôture)
|
||||
```
|
||||
|
||||
## 12. Définition de « terminé »
|
||||
|
||||
DoD V2 §13 (tests unitaires + non-régression, JSDOM si UI, ruff/mypy 0 erreur, i18n FR/EN, CHANGELOG, ROADMAP, fiche par tranche, guide si exposé, CI verte, commit conventionnel référençant l'ID) **plus** les ajouts V2.1 (parent §21) : golden tasks du périmètre vertes ; toute écriture déclare sa post-condition et son inverse ou son absence ; toute source de contenu déclare sa provenance.
|
||||
@@ -0,0 +1,275 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 1 : Le travail (tâches, Aujourd'hui, planification, capture)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **1 sur 5** — le premier jalon « assistant personnel » |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §6 (tâches), §7 (planification), §8 (capture), §14 (UX) |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Prêt à implémenter |
|
||||
| Tranches | **#202** (tâches, projets, objectifs), **#203** (vue Aujourd'hui, file d'approbations unifiée, centre de contrôle), **#204** (moteur de planification), **#205** (capture universelle & Inbox) |
|
||||
| Effort estimé | 14–18 jours |
|
||||
| Prérequis | Phase 0 terminée : mémoire (#192/#193), politiques (#213), runs persistants et niveaux d'autonomie (#206) |
|
||||
| Dépendances | #202 ← #193 ; #203 ← #202, #206 ; #204 ← #202 ; #205 ← #202. **Pas de dépendance à #200** : voir §7.1 pour la résolution explicite |
|
||||
| Débloque | Phase 2 (rappels liés aux tâches, revues ritualisées, briefing-plan), phase 4 (engagements rattachés aux tâches et aux personnes) |
|
||||
|
||||
> **Résultat visible à la fin de cette phase.** L'assistant planifie et agit sur le vault **sans aucun connecteur**. Les tâches vivent dans les notes (checklists enrichies), indexées et interrogeables ; un projet sans prochaine action se voit. Chaque matin, Bruno peut demander son **plan du jour** : agenda du vault + tâches dues + capacité déclarée → proposition en blocs de temps qui **signale la surcharge au lieu de la cacher**, négociable en conversation. L'écran **Aujourd'hui** montre le plan, les priorités, la file unique de tout ce qui attend sa décision, et l'activité de l'assistant. Et tout ce qui lui passe par la tête se capture en 5 secondes (panneau, mobile, voix) dans une **Inbox** que l'assistant trie en propositions validables.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Périmètre](#2-périmètre)
|
||||
3. [État d'entrée — ce que la phase 0 a livré](#3-état-dentrée--ce-que-la-phase-0-a-livré)
|
||||
4. [Modèle de données](#4-modèle-de-données)
|
||||
5. [Tickets de travail](#5-tickets-de-travail)
|
||||
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
|
||||
7. [Comportements détaillés](#7-comportements-détaillés)
|
||||
8. [Tests et golden tasks](#8-tests-et-golden-tasks)
|
||||
9. [Critères d'acceptation de la phase](#9-critères-dacceptation-de-la-phase)
|
||||
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
|
||||
11. [Ordre d'exécution](#11-ordre-dexécution)
|
||||
12. [Définition de « terminé »](#12-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **le travail est un objet, pas un texte (P10).** À la fin de la phase, rien d'important pour Bruno n'existe seulement « dans une conversation » ou « quelque part dans une note » : c'est une tâche datée, un projet avec une prochaine action, ou un item d'inbox en attente de tri.
|
||||
|
||||
Corollaires :
|
||||
|
||||
- **Markdown d'abord, comme la mémoire.** La tâche vit sur sa ligne, dans la note du projet ; l'index SQLite est reconstructible et tout désaccord se résout en faveur de la note.
|
||||
- **Le plan propose, il n'ordonne pas.** Le moteur calcule (déterministe), le modèle rédige et négocie ; la validation est humaine (ou déléguée bornée, phase 0).
|
||||
- **La surcharge se dit.** Si les tâches retenues dépassent la capacité, le plan le signale et propose des reports explicites. Un plan irréaliste est un défaut du système.
|
||||
- **Une seule file de décisions.** Faits `pending`, plans, triages : tout passe par la même file (#203), pour que « ce que l'assistant attend de moi » tienne en un écran et moins de 5 minutes par jour.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### 2.1 Inclus
|
||||
|
||||
- #202 : syntaxe de tâche (états, attributs `[due::] [pri::] [est::] [ctx::] [energy::] [every::]`), parseur et réécriture de lignes, index `data/tasks.sqlite3` maintenu par le watcher, projets et objectifs par frontmatter, récurrence **RRULE** (sous-ensemble RFC 5545), outils `task.*`/`project.*`/`goal_review`, API tâches.
|
||||
- #203 : `GET /api/today` et la vue Aujourd'hui (plan, priorités, file de décisions, inbox, activité) ; file d'approbations unifiée `approvals` (migration des cartes mémoire de la phase 0) ; centre de contrôle v1 (runs, délégations, budgets, reconstruction d'index).
|
||||
- #204 : moteur de planification déterministe (scoring explicable, capacité et créneaux protégés tirés du profil), plan du jour en note `Plans/YYYY-MM-DD.md` + objet plan, time blocking dans les trous d'agenda, négociation par `plan_update`, replanification du reste de journée, agrégats de revue (jour/semaine) — le plan automatique du matin est livré comme **tâche intégrée typée** du scheduler (§7.1), la généralisation `agent_prompt` restant en phase 2.
|
||||
- #205 : Inbox (store, API, outil `inbox_add`), canaux de capture (panneau ⚡, préfixe `+` au chat, Web Share Target PWA, API/MCP), triage assisté par cartes (tâche/note/rappel/fait candidat/événement/à jeter), file hors-ligne rejouée avec horodatage de capture.
|
||||
|
||||
### 2.2 Exclu (phases ultérieures)
|
||||
|
||||
- Les **rappels** comme objets livrés à heure dite (#199, phase 2) : le triage peut *proposer* un rappel, sa création effective attend #199 ; en phase 1, la proposition est convertie en tâche due datée (comportement documenté dans l'UI de la carte).
|
||||
- Le **briefing** (#200, phase 2) et les **rituels** complets (#214, phase 2) : la phase livre le plan et les données de revue ; leur mise en rituel automatique est en phase 2.
|
||||
- Tout **agenda externe** : l'« agenda » de la phase = événements notés dans le vault (notes datées, section Temps du profil) ; l'écriture des blocs dans Google/Outlook arrive en phase 3 avec les connecteurs. Les blocs du plan vivent dans la note du plan.
|
||||
- La transcription vocale (#209, phase 4) : la capture voix de la phase utilise la dictée du navigateur quand elle existe, sinon un texte saisi.
|
||||
|
||||
## 3. État d'entrée — ce que la phase 0 a livré
|
||||
|
||||
| Élément | Usage en phase 1 |
|
||||
|---|---|
|
||||
| Runs persistants, plans, approbations par run (#206) | Le triage, la génération de plan et ses ajustements sont des runs ; la file d'approbations consomme les demandes d'approbation des runs |
|
||||
| Moteur de politiques (#213) | Toute création de tâche/note depuis l'inbox ou un plan passe par la politique (provenance incluse) |
|
||||
| Mémoire et compileur de contexte (#192) | Le plan consomme capacité et créneaux protégés depuis le profil ; le compileur reçoit la section « Aujourd'hui » (plan résumé, tâches dues) |
|
||||
| Miroir à balises gérées (#193) | Même patron pour `Plans/`, `Taches.md` et les sections gérées des notes projets |
|
||||
| Watcher et index du vault | Source des événements de re-parse des tâches |
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
Repris du parent : table `tasks` (§6.1 — id ancré, états, projet/objectif, échéances, priorité, estimation, contexte, énergie, `rrule`, `waiting_on`, source), table `inbox_items` (§8.2). Ajouts de la phase :
|
||||
|
||||
```sql
|
||||
CREATE TABLE plans (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL, -- jour local du plan
|
||||
status TEXT NOT NULL DEFAULT 'proposed' -- proposed|validated|in_progress|closed
|
||||
CHECK (status IN ('proposed','validated','in_progress','closed')),
|
||||
capacity_min INTEGER NOT NULL, -- capacité retenue pour ce jour
|
||||
blocks_json TEXT, -- blocs : tâche/événement, début, fin, source
|
||||
overload_json TEXT, -- surcharge calculée + reports proposés
|
||||
note_path TEXT, -- 99_Assistant/Plans/YYYY-MM-DD.md
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE approvals (
|
||||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- fait|plan|envoi|action_write|action_dangerous|
|
||||
-- triage|contradiction|delegation_review
|
||||
run_id TEXT, -- run demandeur (nullable pour les cartes mémoire)
|
||||
payload_json TEXT NOT NULL, -- ce qui est à décider, affichable tel quel
|
||||
status TEXT NOT NULL DEFAULT 'pending' -- pending|approved|rejected|expired
|
||||
CHECK (status IN ('pending','approved','rejected','expired')),
|
||||
created_at TEXT NOT NULL, decided_at TEXT, expires_at TEXT
|
||||
);
|
||||
CREATE INDEX idx_approvals_pending ON approvals(user_id, status, created_at);
|
||||
```
|
||||
|
||||
`approvals` vit dans `data/assistant.sqlite3` (créé en phase 0). Une approbation **périmée ne s'exécute jamais** : à l'expiration, le run demandeur est notifié et l'action associée est abandonnée (journalisé).
|
||||
|
||||
## 5. Tickets de travail
|
||||
|
||||
*Convention : chaque ticket est indépendamment testable ; « Fichiers » donne les points d'ancrage d'après la V2.1 — à confirmer contre le code réel au démarrage du ticket.*
|
||||
|
||||
### Tâches & projets — #202
|
||||
|
||||
**P1-T01 — Syntaxe et parseur de tâches.**
|
||||
Fichiers : nouveau `backend/tasks/model.py`.
|
||||
Travail : grammaire des états (`[ ] [/] [x] [-] [!]` + motif « bloqué : » en sous-ligne) et des attributs entre crochets (tous optionnels, ordre libre, texte libre préservé) ; parse d'un fichier markdown → tâches avec position (ligne) ; réécriture d'une ligne à l'identique hors la modification demandée (aucune reformatage parasite) ; ancrage `t-<uuid4>` en commentaire HTML discret **seulement** pour les tâches référencées (rappel, run, plan).
|
||||
Acceptation : aller-retour parse → réécriture sans changement = fichier identique au caractère près ; les checklists Obsidian existantes du vault de test sont lues sans perte.
|
||||
|
||||
**P1-T02 — Index des tâches et watcher.**
|
||||
Fichiers : `backend/tasks/store.py`, `data/tasks.sqlite3`, branchement au watcher existant.
|
||||
Travail : DDL du parent §6.1 ; re-parse incrémental par fichier modifié ; reconstruction complète à la demande (bouton centre de contrôle) et au démarrage si l'index est absent/corrompu ; le désaccord se résout en faveur de la note.
|
||||
Acceptation : créer/modifier/cocher une tâche dans l'éditeur met l'index à jour en < 2 s ; supprimer l'index et redémarrer le reconstruit à l'identique.
|
||||
|
||||
**P1-T03 — Projets et objectifs.**
|
||||
Travail : détection par frontmatter (`obsigate: projet|objectif`) ou dossier projets configurable ; état du projet (actif/pause/terminé/archivé), prochaine action = première tâche ouverte liée ; calcul « projet sans prochaine action » exposé à l'index et à l'API (le détecteur d'insight est en phase 4, la donnée est ici) ; objectifs : horizon, mesure, projets liés.
|
||||
Acceptation : un projet actif sans tâche ouverte est retourné par `project_status` avec le drapeau `no_next_action = true`.
|
||||
|
||||
**P1-T04 — Récurrence RRULE.**
|
||||
Fichiers : `backend/tasks/rrule.py` (stdlib + `zoneinfo`, sous-ensemble RFC 5545 : `FREQ`, `INTERVAL`, `BYDAY`, `BYMONTHDAY`, `COUNT`, `UNTIL`).
|
||||
Travail : ancrage à l'**heure murale** dans `OBSIGATE_TZ` (jamais un timestamp UTC figé) ; à la complétion d'une occurrence, création de la suivante selon le mode (série/instance) ; dates de fin respectées.
|
||||
Acceptation : « tous les mar/jeu pendant 8 semaines » produit exactement 16 occurrences ; le passage à l'heure d'hiver ne décale aucune heure murale (tests sur `America/Toronto`).
|
||||
|
||||
**P1-T05 — Outils tâches et API.**
|
||||
Fichiers : `backend/tools/tasks.py`, `backend/routers/tasks.py`.
|
||||
Travail : `task_list` (filtres today/overdue/project/context/waiting), `task_create` (note cible : note du projet, sinon `99_Assistant/Taches.md`), `task_update`, `task_complete` (avec récurrence T04), `project_status`, `goal_review` ; endpoints REST miroirs + recherche plein texte sur les titres.
|
||||
Acceptation : une tâche créée en conversation se retrouve dans la note, dans l'index et dans la réponse de `task_list(filter=today)` si elle est due ce jour.
|
||||
|
||||
### Aujourd'hui & approbations — #203
|
||||
|
||||
**P1-T06 — Agrégat `GET /api/today`.**
|
||||
Fichiers : `backend/routers/today.py`.
|
||||
Travail : assemblage lecture seule : plan du jour + capacité restante, 3 priorités, compteurs file de décisions, inbox (nombre, plus ancien), tâches dues/en retard, activité des runs (24 h), alertes (index périmé, run en échec) ; chaque section porte ses liens d'objet ; temps de réponse cible < 300 ms sur le vault de test (agrégats indexés, aucun balayage de fichiers).
|
||||
Acceptation : l'agrégat est cohérent avec les endpoints de détail (tests croisés) ; aucune section ne fait échouer les autres (dégradation par section).
|
||||
|
||||
**P1-T07 — Vue Aujourd'hui.**
|
||||
Fichiers : frontend (SPA), patron de la maquette du parent §14.1.
|
||||
Travail : sections plan/priorités/décisions/inbox/activité ; actions sur place (valider un bloc, reporter une tâche, ouvrir la file) sans quitter l'écran ; états vides honnêtes (« pas de plan : en proposer un ») ; responsive et PWA (c'est l'écran d'accueil mobile de l'assistant).
|
||||
Acceptation : tests JSDOM sur les sections et les actions ; l'écran reste utilisable avec zéro connecteur, zéro plan et une inbox vide.
|
||||
|
||||
**P1-T08 — File d'approbations unifiée.**
|
||||
Fichiers : `backend/routers/approvals.py`, migration des cartes mémoire.
|
||||
Travail : table `approvals` (§4) ; les cartes `pending` de la phase 0 (faits) sont re-présentées comme des approvals de type `fait` sans perte d'état ; regroupement par run et par lot (dépliable) ; expiration honorée ; historique des décisions dans l'audit ; badge de compteur partagé (Aujourd'hui, panneau, Configurations).
|
||||
Acceptation : un fait `pending`, un plan à valider et un lot de triages coexistent dans une seule file triée par ancienneté ; approuver un lot de 12 items crée 12 décisions tracées, pas une.
|
||||
|
||||
**P1-T09 — Centre de contrôle v1.**
|
||||
Travail : page Configurations « Assistant » : runs (liste/détail/reprise/annulation — déjà en phase 0, ici intégrés), délégations et niveaux par domaine, budgets (tokens, et compteur d'approbations du jour), santé des index (tâches, mémoire) + bouton « reconstruire l'index des tâches », liens vers les miroirs `99_Assistant/`.
|
||||
Acceptation : chaque compteur affiché correspond à une métrique journalisée (pas d'affichage décoratif) ; la reconstruction d'index est un run visible.
|
||||
|
||||
### Planification — #204
|
||||
|
||||
**P1-T10 — Moteur de scoring et capacité.**
|
||||
Fichiers : `backend/tasks/planner.py`.
|
||||
Travail : entrées déterministes (§7.1 du parent) : événements du vault, tâches dues + en retard, capacité déclarée (réglage + section « Temps » du profil : heures de focus, créneaux protégés), rituels absents cette phase ; scoring explicable (échéance > priorité > âge > lien objectif ; pénalité d'énergie incompatible, règle issue du profil uniquement) ; chaque tâche retenue ou écartée porte sa **raison** affichable.
|
||||
Acceptation : à entrées égales, plan proposé identique (déterminisme) ; la raison d'exclusion d'une tâche est consultable et correcte.
|
||||
|
||||
**P1-T11 — Génération du plan du jour.**
|
||||
Travail : objet `plans` (§4) + note `99_Assistant/Plans/YYYY-MM-DD.md` à balises gérées ; time blocking dans les trous d'agenda (durée estimée, défaut 45 min **marqué estimé**) ; calcul de surcharge : si la somme dépasse la capacité, `overload_json` liste les reports proposés (jamais de compression silencieuse) ; rédaction LLM (1 appel, modèle de routage « planification ») avec citation de la source de chaque ligne.
|
||||
Acceptation : G2 passe (journée surchargée → surcharge signalée, reports proposés, aucun bloc inventé) ; la note du plan et l'objet plan restent synchronisés après édition des blocs gérés.
|
||||
|
||||
**P1-T12 — Négociation et replanification.**
|
||||
Fichiers : `backend/tools/planner.py` (`plan_today`, `plan_propose`, `plan_update`, `plan_review`).
|
||||
Travail : ajustements conversationnels (« déplace X à demain », « je n'ai que 2 h ») → réécriture de la proposition, revalidation ; replanification du **reste de journée** uniquement, diff lisible (« X glisse à 15 h 30, Y reporté ») ; seuil d'impact : tout report d'une tâche `pri: haute+` exige validation.
|
||||
Acceptation : une replanification ne touche jamais un bloc passé ; le diff affiché correspond exactement aux changements appliqués à la validation.
|
||||
|
||||
**P1-T13 — Revues : agrégats jour et semaine.**
|
||||
Travail : `plan_review(period)` déterministe sans LLM : fait/pas fait/reporté (avec compteur de reports par tâche — la donnée du détecteur « reportée ≥ 3 fois » de la phase 4), projets sans prochaine action, progrès objectifs ; rédaction de la note de revue de semaine (le rituel automatisé est en phase 2, la fonction est ici, appelable à la demande et par skill `/revue`).
|
||||
Acceptation : une tâche reportée 3 fois est comptée 3 fois, avec ses dates ; la revue ne contient aucun chiffre non traçable à l'index.
|
||||
|
||||
**P1-T14 — Déclenchement du plan sans #200.**
|
||||
Travail : tâche intégrée typée du scheduler existant (`action: plan_propose`, heure configurable, défaut 07:00, politique de rattrapage simple : si l'occurrence du jour manque, proposer à la première ouverture d'Aujourd'hui) ; skill `/planjour` ; **aucune** utilisation d'`agent_prompt` générique (phase 2).
|
||||
Acceptation : à 7 h, la proposition du jour existe ; conteneur arrêté à 7 h puis rouvert à 9 h → la proposition est générée à l'ouverture, datée du jour, sans doublon.
|
||||
|
||||
### Capture & Inbox — #205
|
||||
|
||||
**P1-T15 — Store Inbox et outil d'ajout.**
|
||||
Fichiers : `backend/inbox/store.py`, `backend/tools/inbox.py`, `data/assistant.sqlite3`.
|
||||
Travail : table `inbox_items` (parent §8.2) ; `inbox_add` (WRITE sans classement — la capture ne décide rien), `inbox_list` ; `POST /api/inbox` authentifié, exposé au MCP ; horodatage de capture conservé séparément de l'horodatage de synchronisation.
|
||||
Acceptation : un item ajouté par API, par chat et par MCP a la même forme et le même statut initial `new`.
|
||||
|
||||
**P1-T16 — Canaux de capture.**
|
||||
Travail : bouton « ⚡ Capture » du panneau (aucun appel LLM) ; préfixe `+` dans le chat général et le chat de fichier ; Web Share Target de la PWA (titre + URL + texte) ; bouton capture de la vue Aujourd'hui mobile ; dictée navigateur si disponible (sinon saisie texte).
|
||||
Acceptation : capture mobile de bout en bout en < 5 s (mesuré sur le parcours PWA) ; une capture ne déclenche aucun run LLM (vérifié au journal).
|
||||
|
||||
**P1-T17 — Triage assisté.**
|
||||
Fichiers : `backend/tools/inbox.py` (`inbox_triage`), prompt de triage (annexe B.1 du parent).
|
||||
Travail : proposition typée par item (tâche avec échéance **uniquement si énoncée ou déductible d'une date explicite** — aucune date inventée ; note avec dossier proposé ; rappel → tâche datée cette phase, §2.2 ; fait candidat → pipeline mémoire `pending` ; événement vault ; à jeter) ; présentation en cartes dans la file unifiée (type `triage`), validation/correction/rejet unitaire ou en lot pour les évidents ; application = un run par lot, via la politique (provenance `inbox`).
|
||||
Acceptation : G4 passe (compte rendu collé dans l'inbox → tâches datées + proposition d'engagement en cartes, rien créé sans validation) ; un item sans date explicite produit une tâche **sans** échéance.
|
||||
|
||||
**P1-T18 — Hors-ligne.**
|
||||
Travail : les captures PWA hors-ligne entrent dans la file IndexedDB existante avec leur horodatage d'origine et sont rejouées à la reconnexion ; la résolution des dates relatives (« demain ») se fait à la date de **capture** ; sur Tauri hors-ligne, même file locale ; le triage différé (serveur injoignable) ne perd ni ne duplique aucun item.
|
||||
Acceptation : capture hors-ligne puis reconnexion = un seul item, daté de la capture ; rejouer deux fois la file ne crée aucun doublon (clé d'idempotence par item).
|
||||
|
||||
### Finitions
|
||||
|
||||
**P1-T19 — Skills et surfaces conversationnelles.**
|
||||
Travail : skills `/planjour`, `/revue`, `/trier`, `/taches` ; section « Aujourd'hui » du compileur de contexte (plan résumé + tâches dues, budget borné) ; libellés `ai.step.*` pour les outils tâches/plan/inbox.
|
||||
Acceptation : « qu'est-ce que j'ai aujourd'hui ? » en conversation répond depuis l'agrégat Aujourd'hui, sources citées, sans accès fichier direct.
|
||||
|
||||
**P1-T20 — i18n, docs, guides.**
|
||||
Travail : clés FR/EN (vue Aujourd'hui, cartes, plans, inbox) ; fiches `docs/features/` pour #202–#205 ; guide utilisateur « Tâches, plan du jour et capture » ; CHANGELOG + ROADMAP.
|
||||
Acceptation : DoD du §12 vérifiée point par point.
|
||||
|
||||
## 6. API livrée par la phase
|
||||
|
||||
| Route | Rôle | Ticket |
|
||||
|---|---|---|
|
||||
| `GET /api/today` | Agrégat de la vue Aujourd'hui | P1-T06 |
|
||||
| `GET/POST/PATCH /api/tasks` · `/api/tasks/{id}/complete` | CRUD et complétion des tâches (miroir des outils) | P1-T05 |
|
||||
| `GET /api/projects` · `GET /api/projects/{id}/status` | Projets et leur état calculé | P1-T03/T05 |
|
||||
| `GET /api/plans/{date}` · `POST /api/plans/{date}/propose|update|validate` | Plan du jour | P1-T11/T12 |
|
||||
| `GET /api/approvals` · `POST /api/approvals/{id}/decide` · `POST /api/approvals/decide-batch` | File de décisions unifiée | P1-T08 |
|
||||
| `GET/POST /api/inbox` · `POST /api/inbox/{id}/triage` | Capture et triage | P1-T15/T17 |
|
||||
|
||||
## 7. Comportements détaillés
|
||||
|
||||
### 7.1 Dépendance #204 → #200 : résolution
|
||||
|
||||
Le tableau des tranches du parent donnait #204 dépendant de #200 (`agent_prompt`), alors que l'ordre des phases place #200 en phase 2. **Résolution adoptée dans les documents de phase** : le plan du matin n'a pas besoin d'un prompt libre — c'est une fonction typée. La phase 1 le livre comme tâche intégrée du scheduler (P1-T14) ; la phase 2 généralise (#200 : `agent_prompt` arbitraire, briefing comme amorce rédactionnelle du plan) sans changer le comportement du plan. Le parent est corrigé en conséquence (§19.2 : dépendance de #204 = #202, #193).
|
||||
|
||||
### 7.2 Tâche ou rappel ?
|
||||
|
||||
En phase 1, il n'existe qu'un objet daté : la tâche. La carte de triage qui propose « rappel » crée une tâche due avec `ctx: rappel` ; en phase 2, #199 rattache la livraison (push/canaux, snooze, escalade) à ces mêmes tâches — aucune migration d'objets, un enrichissement de comportement.
|
||||
|
||||
### 7.3 Édition concurrente d'une ligne de tâche
|
||||
|
||||
Si la ligne d'une tâche a changé dans la note entre la lecture et la réécriture demandée par un outil (édition humaine entre-temps), l'outil refuse avec un conflit lisible et relit l'état courant — jamais d'écrasement. Le backup automatique existant couvre les écritures.
|
||||
|
||||
### 7.4 Capacité et créneaux protégés
|
||||
|
||||
La capacité vient d'abord du réglage, puis de la section « Temps » de `Profil.md` si Bruno l'édite (source `user`, prioritaire). Le moteur n'infère **jamais** une capacité depuis l'historique sans la proposer en carte (ce serait une procédure — phase 2 via la consolidation, validation humaine).
|
||||
|
||||
## 8. Tests et golden tasks
|
||||
|
||||
- Unitaires : parseur (aller-retour sans perte), RRULE (16 occurrences, heure d'hiver), scoring (déterminisme, raisons), surcharge (aucune compression), approvals (expiration, lots).
|
||||
- Intégration : tâche créée par 3 surfaces → même objet ; plan généré → négocié → validé → revu ; capture hors-ligne → synchro → triage.
|
||||
- Golden tasks de la phase : **G2** (surcharge), **G4** (triage d'un compte rendu) — définitions au parent §16.1 ; plus deux scénarios locaux : « tâche créée en chat visible aux 3 surfaces » et « plan du matin après redémarrage » (P1-T14).
|
||||
|
||||
## 9. Critères d'acceptation de la phase
|
||||
|
||||
1. Une tâche créée en conversation, dans l'éditeur ou par triage d'inbox est le **même objet** dans la note, l'index et Aujourd'hui.
|
||||
2. Le plan d'une journée surchargée signale la surcharge et propose des reports ; aucun bloc n'est inventé et aucune tâche n'est compressée en silence.
|
||||
3. La file de décisions contient faits, plans et triages ; le temps de décision quotidien est mesuré et affiché au centre de contrôle.
|
||||
4. Une capture mobile prend moins de 5 secondes ; une capture hors-ligne n'est ni perdue ni dupliquée.
|
||||
5. Aucun connecteur n'est requis pour l'ensemble de la phase.
|
||||
|
||||
## 10. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| Friction de la syntaxe d'attributs dans les notes | Tous les attributs sont optionnels ; une checklist ordinaire reste une tâche valide (échéance absente = jamais proposée comme « due ») |
|
||||
| L'index et les notes divergent (édition externe, Syncthing) | Reconstruction complète toujours disponible ; les conflits de ligne sont des refus lisibles (§7.3), pas des écrasements |
|
||||
| Le plan du jour est ignoré s'il arrive trop tard | Proposition à 7 h **et** à la première ouverture d'Aujourd'hui ; la métrique « plans validés/modifiés/ignorés » est suivie dès cette phase (#212) |
|
||||
| La file de décisions devient un deuxième travail | Lots, expiration, et métrique du temps de décision : au-delà de 5 min/jour en moyenne, c'est un signal de défaut à traiter (parent §20) |
|
||||
|
||||
## 11. Ordre d'exécution
|
||||
|
||||
```
|
||||
P1-T01 → T02 → T03 → T04 → T05 (#202 — T04 peut paralléliser après T01)
|
||||
P1-T06 → T07 ; P1-T08 ; P1-T09 (#203 — T08 après T05, T06 après T11 pour le plan réel,
|
||||
mais livrable avant avec section plan vide honnête)
|
||||
P1-T10 → T11 → T12 → T13 → T14 (#204)
|
||||
P1-T15 → T16 → T17 → T18 (#205)
|
||||
P1-T19 → T20 (finitions)
|
||||
```
|
||||
|
||||
## 12. Définition de « terminé »
|
||||
|
||||
DoD du document de phase 0 §12, inchangée, plus : les golden tasks G2 et G4 vertes en CI ; la reconstruction d'index démontre l'égalité index ↔ notes sur le vault de test.
|
||||
@@ -0,0 +1,254 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 2 : Le temps réel (agent programmé, consolidation, rappels, automatisations, rituels)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **2 sur 5** — l'assistant devient proactif, dans un budget |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §9.5–9.6 (consolidation), §12 (automatisations), §13.3 (rituels), §14.4 (budget d'interruption) |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Prêt à implémenter |
|
||||
| Tranches | **#200** (agent programmé + briefing), **#194** (consolidation nocturne), **#199** (rappels), **#208** (automatisations événementielles & recettes), **#214** (rituels & habitudes) |
|
||||
| Effort estimé | 14–19 jours |
|
||||
| Prérequis | Phase 0 (runs #206, politiques #213, mémoire #192/#193) et phase 1 (tâches #202, plan #204, inbox #205, file d'approbations #203) terminées |
|
||||
| Dépendances | #200 ← #192, #206 ; #194 ← #200, #193 ; #199 ← #200, #202 ; #208 ← #206, #202 ; #214 ← #204, #208 |
|
||||
| Débloque | Phase 3 (les automatisations et le briefing consommeront les événements connecteurs), phase 4 (la proactivité #201 s'appuie sur les détecteurs et le budget livrés ici) |
|
||||
|
||||
> **Résultat visible à la fin de cette phase.** L'assistant travaille **quand Bruno n'est pas là** — de façon bornée, journalisée et révocable. Le scheduler sait lancer l'agent (`agent_prompt` à whitelist) : le briefing du matin devient l'amorce rédigée du plan du jour. Chaque nuit, la consolidation extrait faits, entités et contradictions des conversations et des notes, en cartes validables, sous budget de tokens. Les rappels sont de vrais objets livrés à heure dite (push, canaux, escalade, snooze compté) et peuvent être **contextuels** (« quand j'ouvre le projet X »). Les automatisations réagissent aux événements du vault et des tâches, avec dry-run et circuit-breaker. Et les rituels — matin, soir, semaine — tiennent la boucle : rien ne glisse en silence d'un jour à l'autre.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Périmètre](#2-périmètre)
|
||||
3. [État d'entrée](#3-état-dentrée)
|
||||
4. [Modèle de données](#4-modèle-de-données)
|
||||
5. [Tickets de travail](#5-tickets-de-travail)
|
||||
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
|
||||
7. [Comportements détaillés](#7-comportements-détaillés)
|
||||
8. [Tests et golden tasks](#8-tests-et-golden-tasks)
|
||||
9. [Critères d'acceptation de la phase](#9-critères-dacceptation-de-la-phase)
|
||||
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
|
||||
11. [Ordre d'exécution](#11-ordre-dexécution)
|
||||
12. [Définition de « terminé »](#12-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **l'autonomie se prouve en journal, pas en promesse.** Tout ce que la phase ajoute d'automatique — un run planifié, une consolidation, une automatisation — est un run ordinaire de la phase 0 : visible, plafonné, reprenable, annulable, et soumis au moteur de politiques et au **budget d'interruption** (P15).
|
||||
|
||||
Corollaires :
|
||||
|
||||
- **Pas de magie par défaut.** Chaque tâche planifiée et chaque automatisation est créée par Bruno ou proposée en carte puis validée ; l'installation ne crée que le briefing-plan (désactivable) et la consolidation (désactivable).
|
||||
- **Les actions primitives d'abord.** Une automatisation qui peut s'exprimer sans LLM (créer une tâche, notifier, classer) ne doit pas appeler de LLM : c'est plus fiable, gratuit et auditable.
|
||||
- **L'échec est un état, pas un silence.** Échec de run planifié = journal + notification de synthèse au briefing suivant ; échecs répétés d'une automatisation = suspension + insight, jamais une boucle d'erreurs.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### 2.1 Inclus
|
||||
|
||||
- #200 : action scheduler `agent_prompt` (whitelist de capacités V2 renforcée : READ libre ; WRITE restreint aux chemins déclarés, défaut `99_Assistant/**` ; DANGEROUS et écritures connecteurs interdits ; `allowed_extra_tools` explicite), plafonds (8 itérations par défaut, budget tokens par tâche), runs `origin='scheduled'` unifiés au modèle de la phase 0 (reprise des runs planifiés interrompus), briefing du matin comme amorce rédactionnelle du plan (skill `/brief`), tâches intégrées déclarées dans l'UI avec leurs capacités.
|
||||
- #194 : consolidation nocturne complète (parent §9.5) : collecte incrémentale (sessions, notes, tâches clôturées, revues), extraction LLM sous budget dur, résolution d'entités, contradictions explicites vers la file d'approbations, candidats connecteurs en réglage `off|candidats` (mécanisme livré ; les sources arrivent en phase 3), journal dans `Journal.md`.
|
||||
- #199 : action scheduler `reminder` et livraison multi-canal (Web Push + `notify.broadcast` existant), outils `reminder_*`, RRULE (moteur de la phase 1), rattachement aux tâches (une tâche due peut être livrée comme rappel — §7.2 du document phase 1), snooze compté, escalade optionnelle, section Rappels d'Aujourd'hui.
|
||||
- #208 : bus d'événements interne (vault, tâches, inbox, runs, horloge, webhooks entrants), moteur d'automatisations (note markdown à frontmatter structuré : déclencheur → conditions → actions primitives ou `agent_prompt` borné), gardes (`frequence_max` obligatoire, dédup par clé d'événement, profondeur d'enchaînement ≤ 2, circuit-breaker à 3 échecs), dry-run sur événements passés, scheduler amendé (cron complet, politique de rattrapage `misfire`, idempotence par occurrence), activation des **rappels contextuels** de #199 sur le bus, catalogue initial de recettes (annexe D du parent) en modèles.
|
||||
- #214 : rituels livrés comme recettes assemblées sur l'existant (briefing/plan du matin, revue du soir à reports explicites, revue de semaine, inbox zéro) ; habitudes = tâches récurrentes rattachées à un objectif avec compteur de série ; série cassée → au plus un insight hebdomadaire.
|
||||
|
||||
### 2.2 Exclu (phases ultérieures)
|
||||
|
||||
- Les événements `connector.*` : le bus les **déclare** dès cette phase (schéma d'événement), le moteur de sync qui les émet est en phase 3. Aucune automatisation livrée ne dépend d'un connecteur.
|
||||
- Les détecteurs d'insights complets (#201, phase 4) : la phase livre les **données** (snooze compté, reports comptés, séries) et le canal `Insights.md` ; les détecteurs métier sont en phase 4.
|
||||
- Les engagements (#211, phase 4) : la consolidation peut déjà émettre des faits `open_loop` (V2) ; leur promotion en objets suivis est en phase 4.
|
||||
|
||||
## 3. État d'entrée
|
||||
|
||||
| Élément | Usage en phase 2 |
|
||||
|---|---|
|
||||
| Scheduler #170 (`interval_hours`/`daily_time`/`once_at`, actions primitives) | Socle étendu, jamais remplacé ; les tâches existantes des utilisateurs continuent de fonctionner |
|
||||
| Runs et politiques (phase 0) | Tout travail automatique est un run sous politique ; les plafonds par tâche s'ajoutent aux plafonds par run |
|
||||
| Tâches RRULE, plan, revues (phase 1) | Les rappels s'appuient sur l'index des tâches ; le briefing-plan consomme le plan ; les rituels appellent `plan_review` |
|
||||
| File d'approbations (phase 1) | Contradictions de consolidation, propositions d'automatisations et faits `pending` y convergent |
|
||||
| Notifications #168 + Web Push #67 | Bras de livraison des rappels et des alertes, sous budget d'interruption |
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
Pas de nouvelle base : extensions de `data/assistant.sqlite3` et du fichier des tâches planifiées existant.
|
||||
|
||||
```sql
|
||||
CREATE TABLE automation_runs ( -- journal d'exécution des automatisations
|
||||
id TEXT PRIMARY KEY, automation_id TEXT NOT NULL, -- note d'automatisation
|
||||
event_key TEXT NOT NULL, -- clé de déduplication de l'événement
|
||||
run_id TEXT, -- run phase 0 associé (si agent_prompt)
|
||||
outcome TEXT NOT NULL, -- fired|skipped_freq|skipped_cond|failed|dry_run
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_autorun_event ON automation_runs(automation_id, event_key);
|
||||
CREATE TABLE event_log ( -- journal échantillonné du bus
|
||||
id TEXT PRIMARY KEY, type TEXT NOT NULL, payload_summary TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
Les automatisations elles-mêmes sont des **notes** (`99_Assistant/Automatisations/<nom>.md`, frontmatter du parent §12.2) : pas de table de définition, un index en mémoire reconstruit par le watcher, comme les tâches. Les compteurs du circuit-breaker vivent en base (état, pas contenu).
|
||||
|
||||
Amendement du scheduler : chaque tâche planifiée gagne `cron_expr` (optionnel), `misfire` (`skip|run_once|run_all`, défaut `run_once` pour les tâches `agent_prompt` et `reminder`, `skip` sinon) et `idempotency_scope` (tâche + occurrence) ; l'ancien format reste lisible (migration douce au chargement, réécriture au premier enregistrement).
|
||||
|
||||
## 5. Tickets de travail
|
||||
|
||||
*Convention : chaque ticket est indépendamment testable ; « Fichiers » donne les points d'ancrage d'après la V2.1 — à confirmer contre le code réel au démarrage du ticket.*
|
||||
|
||||
### Agent programmé & briefing — #200
|
||||
|
||||
**P2-T01 — Action `agent_prompt` dans le scheduler.**
|
||||
Fichiers : `backend/scheduler.py`, `backend/tools/scheduled.py`.
|
||||
Travail : nouveau type d'action lançant `run_agent` avec un prompt, une whitelist de capacités (défaut du parent §12.2 : READ libre, WRITE `99_Assistant/**`, DANGEROUS et écritures externes interdits, `allowed_extra_tools` explicite par tâche), plafonds propres (itérations, tokens) ; la tâche déclare ses capacités dans l'UI des tâches planifiées (liste lisible, pas du JSON brut).
|
||||
Acceptation : une tâche `agent_prompt` tentant d'écrire hors de son périmètre échoue proprement, l'échec est journalisé et visible ; aucune tâche existante (#170) n'est affectée.
|
||||
|
||||
**P2-T02 — Unification aux runs et reprise.**
|
||||
Travail : chaque exécution planifiée crée un `agent_runs` (`origin='scheduled'`, `origin_ref` = tâche) ; un run planifié interrompu par redémarrage est repris selon les règles de la phase 0 (steps WRITE incertaines → approbation) ou marqué échoué avec motif, selon sa politique de rattrapage ; `last_run_at`/`last_error` persistés et broadcast `schedule_failure` existant conservé.
|
||||
Acceptation : tuer le conteneur pendant le briefing puis le relancer donne un run unique, repris ou échoué explicitement — jamais un briefing en double ni un silence.
|
||||
|
||||
**P2-T03 — Briefing-plan du matin.**
|
||||
Travail : la tâche intégrée de génération de plan (phase 1, P1-T14) est rebranchée : le plan déterministe reste produit par le moteur #204 ; #200 ajoute l'**amorce rédactionnelle** (skill `/brief` : synthèse du plan, faits `pending` du jour, points d'attention) écrite dans `Briefings/YYYY-MM-DD.md` et notifiée selon les canaux et le budget ; en cas d'agenda externe absent, le briefing le dit (données du vault uniquement).
|
||||
Acceptation : à l'heure configurée, `Briefings/` contient la note du jour citant ses sources ; `/brief` régénère à la demande sans dupliquer le plan.
|
||||
|
||||
### Consolidation nocturne — #194
|
||||
|
||||
**P2-T04 — Collecte incrémentale.**
|
||||
Fichiers : `backend/memory/consolidator.py`.
|
||||
Travail : sessions créées/modifiées depuis le dernier run + notes modifiées (index/watcher, plafonds 200 fichiers × 30 Ko) + tâches clôturées et revues de la période ; `redact_file_content()` systématique avant tout envoi LLM ; watermark persisté dans `consolidation_runs` (un run interrompu reprend sa fenêtre, pas plus).
|
||||
Acceptation : un second run sans nouveauté consomme zéro token LLM ; le watermark avance seulement sur succès de l'étape d'écriture.
|
||||
|
||||
**P2-T05 — Extraction et réconciliation.**
|
||||
Travail : prompt d'extraction du parent (annexe B amendée : `procedure`, entité principale, marqueur `contradiction`) ; validation Pydantic stricte de la sortie (rejet des faits non sourcés) ; étape déterministe de résolution d'entités (liaison ou proposition) ; réconciliation : supersede bi-temporel, contradictions avec `decision`/`procedure` → carte `contradiction` dans la file unifiée (jamais de supersede automatique), dossiers sensibles → `pending`.
|
||||
Acceptation : G5 passe (un secret présent dans les notes n'est jamais extrait ; un fait santé est en `pending`) ; une contradiction produit une carte avec les deux sources citées.
|
||||
|
||||
**P2-T06 — Écriture, cartes et budget.**
|
||||
Travail : écriture des faits + miroir avec protection de course (hash comparé avant **et après** l'appel LLM ; section éditée entre-temps = écriture abandonnée pour cette section, replanifiée) ; faits `pending` → file d'approbations ; budget dur `OBSIGATE_CONSOLIDATION_MAX_TOKENS` (arrêt propre, watermark partiel journalisé) ; compte rendu dans `Journal.md` ; réglage candidats connecteurs (§9.6 du parent) présent, défaut `off`, sans source cette phase.
|
||||
Acceptation : une nuit de test sur le vault fixture produit ≥ 1 fait utile et respecte le budget ; éditer `Profil.md` pendant le run ne perd ni l'édition ni le fait.
|
||||
|
||||
**P2-T07 — Déclenchement.**
|
||||
Travail : tâche intégrée `daily_time 23:30` (configurable, `OBSIGATE_TZ`) via #200 (whitelist `memory.*`) ; skill `/consolider` à la demande ; le run apparaît dans le centre de contrôle avec ses compteurs (faits ajoutés/validés/rejetés — métriques #212).
|
||||
Acceptation : la consolidation est visible, désactivable, et son coût de la nuit est affiché le lendemain dans Aujourd'hui (activité de l'assistant).
|
||||
|
||||
### Rappels — #199
|
||||
|
||||
**P2-T08 — Action `reminder` et outils.**
|
||||
Fichiers : `backend/tools/reminders.py`, scheduler.
|
||||
Travail : action typée `{user_id, text, deliver:[channels], rrule?}` ; outils `reminder_create` (dates relatives résolues côté serveur et **confirmées dans la carte** — « dans 3 semaines » affiche la date absolue avant création), `reminder_list`, `reminder_snooze` (compteur persisté), `reminder_cancel` ; les rappels sont des tâches de l'index phase 1 vues par le scheduler de livraison (§7.2).
|
||||
Acceptation : G1 passe (« rappelle-moi d'appeler le dentiste dans 3 semaines » → livraison à la bonne date, heure murale, fuseau Toronto).
|
||||
|
||||
**P2-T09 — Livraison, escalade, section Rappels.**
|
||||
Travail : à l'échéance : Web Push + `notify.broadcast` sur les canaux choisis + trace dans l'historique de `Rappels.md` (vue générée) ; acquittement (depuis la notification, Aujourd'hui ou en conversation) ; escalade optionnelle des rappels `critique` (canal externe si non acquitté après N minutes) ; les rappels dus entrent dans le compileur de contexte (section Aujourd'hui) ; snooze ≥ 3 sur le même rappel = donnée exposée aux insights (phase 4), pas de notification supplémentaire.
|
||||
Acceptation : un rappel non acquitté critique escalade une fois, puis s'arrête ; un rappel ordinaire n'émet qu'une livraison par occurrence (idempotence du scheduler amendé, P2-T13).
|
||||
|
||||
### Automatisations événementielles — #208
|
||||
|
||||
**P2-T10 — Bus d'événements.**
|
||||
Fichiers : `backend/automations/bus.py`.
|
||||
Travail : émission depuis le watcher (`vault.file.*`), l'index des tâches (`task.created|completed|overdue`), l'inbox, les runs (`run.finished`), l'horloge (`schedule.tick`), les webhooks entrants existants ; schéma `connector.object.*` déclaré (aucun émetteur cette phase) ; journal échantillonné `event_log` ; bus en mémoire, aucun broker externe (P1).
|
||||
Acceptation : chaque type d'événement déclaré a au moins un test d'émission ; un abonné lent ne bloque pas l'émetteur (file bornée, perte journalisée).
|
||||
|
||||
**P2-T11 — Moteur d'automatisations.**
|
||||
Fichiers : `backend/automations/engine.py`.
|
||||
Travail : chargement des notes `Automatisations/*.md` (frontmatter validé, erreurs de schéma = automatisation désactivée + carte explicative, jamais de crash) ; évaluation déclencheur → conditions déterministes → actions : primitives (`task_create`, `reminder_create`, `create_file`/`append_to_file` dans le périmètre, `notify`, classement inbox) ou `agent_prompt` (via #200, whitelist propre à l'automatisation) ; chaque exécution est journalisée (`automation_runs`) et, si LLM, un run phase 0.
|
||||
Acceptation : l'exemple du parent §12.2 (forme sans connecteur : « note modifiée dans Projets/ → tâche de relecture ») fonctionne de bout en bout ; une automatisation mal formée est signalée, pas silencieusement ignorée.
|
||||
|
||||
**P2-T12 — Gardes : fréquence, dédup, circuit-breaker, dry-run.**
|
||||
Travail : `frequence_max` obligatoire (refus d'activation sans elle) ; dédup par clé d'événement (index unique §4) ; profondeur d'enchaînement ≤ 2 (`automation → run → automation` au-delà = refus journalisé) ; 3 échecs consécutifs → suspension + insight dans `Insights.md` ; **dry-run** : rejouer les événements des 7 derniers jours et afficher ce qui *aurait* été fait, sans aucun effet.
|
||||
Acceptation : G8 passe (déclencheur répété 5 fois le même jour avec `frequence_max: 1/jour` → 1 exécution, 4 ignorées journalisées) ; le dry-run ne crée ni tâche ni notification (vérifié aux stores).
|
||||
|
||||
**P2-T13 — Scheduler amendé.**
|
||||
Travail : expressions cron complètes en plus des modes existants ; politique `misfire` (§4) ; idempotence par (tâche, occurrence) ; file des échecs avec rejeu manuel depuis les Configurations ; ancrage heure murale `zoneinfo` pour les horaires quotidiens.
|
||||
Acceptation : une occurrence ratée pendant un arrêt du conteneur suit sa politique au redémarrage (test par horloge simulée) ; aucune occurrence n'est livrée deux fois.
|
||||
|
||||
**P2-T14 — Rappels contextuels et recettes initiales.**
|
||||
Travail : déclencheurs d'ouverture (note/projet ouvert → rappels `ctx` correspondants proposés dans le panneau, pas de push) rattachés au bus ; publication des recettes de l'annexe D du parent livrables sans connecteur (briefing-plan, revues, veille de page par `fetch_url` + diff, anniversaires depuis les entités connues, entretien récurrent) comme modèles d'automatisations/skills pré-remplis, activables un par un.
|
||||
Acceptation : la recette « veille de page » détecte un changement et produit une note + un item d'activité, sans notification hors budget.
|
||||
|
||||
### Rituels & habitudes — #214
|
||||
|
||||
**P2-T15 — Rituels assemblés.**
|
||||
Travail : les rituels sont des recettes (pas un moteur nouveau) : matin = briefing-plan (P2-T03) ; soir = revue du jour (`plan_review`) avec **reports explicites** des tâches non faites (nouvelle date choisie ou retour au projet — l'assistant propose, Bruno tranche en un écran) ; semaine = revue de semaine (phase 1) + focus proposés (3 projets max) ; inbox zéro = triage en lot en fin de journée ; chaque rituel est activable/désactivable séparément, à heure configurable.
|
||||
Acceptation : une tâche non faite trois soirs de suite n'a jamais glissé silencieusement : chaque report est daté et motivé dans l'historique de la tâche.
|
||||
|
||||
**P2-T16 — Habitudes et séries.**
|
||||
Travail : une habitude = tâche récurrente RRULE liée à un objectif (phase 1) ; série calculée par l'index (occurrences faites / attendues, fenêtre glissante) ; affichage dans la revue de semaine et la note de l'objectif ; série cassée → au plus **un** insight hebdomadaire regroupé (P15), jamais de rappel quotidien de culpabilisation.
|
||||
Acceptation : la série d'une habitude « 3×/semaine » est calculée correctement sur 4 semaines de fixture, semaines incomplètes (en cours) exclues du jugement.
|
||||
|
||||
### Finitions
|
||||
|
||||
**P2-T17 — Budget d'interruption : mécanique.**
|
||||
Travail : compteur quotidien des notifications sortantes de l'assistant (briefing = 1 ; alertes jusqu'à `OBSIGATE_NOTIFY_DAILY_BUDGET`, défaut 3 ; rappels demandés par Bruno hors budget ; `critique` peut dépasser, 1/jour max) ; sévérités `critique|normale|info` (l'info attend la revue du soir) ; chaque notification porte son pourquoi et le lien de l'objet ; compteur visible au centre de contrôle. Les **détecteurs** qui consommeront ce budget sont en phase 4 (#201) ; la mécanique s'applique dès cette phase aux rappels et aux échecs de runs planifiés.
|
||||
Acceptation : un scénario générant 6 alertes normales en un jour en livre 3 et range les autres dans la revue du soir (vérifié au journal).
|
||||
|
||||
**P2-T18 — i18n, docs, guides.**
|
||||
Travail : clés FR/EN (tâches planifiées et leurs capacités, automatisations, rappels, rituels) ; fiches `docs/features/` pour #200, #194, #199, #208, #214 ; guide « Automatisations et rituels » + pas-à-pas de création d'une automatisation ; CHANGELOG + ROADMAP.
|
||||
Acceptation : DoD du §12 vérifiée point par point.
|
||||
|
||||
## 6. API livrée par la phase
|
||||
|
||||
| Route | Rôle | Ticket |
|
||||
|---|---|---|
|
||||
| `GET/POST/PATCH /api/scheduler/tasks` (étendu) | Tâches planifiées : `agent_prompt`, cron, `misfire`, capacités affichées | P2-T01/T13 |
|
||||
| `POST /api/scheduler/tasks/{id}/run` (existant) | Exécution manuelle — produit un run unifié | P2-T02 |
|
||||
| `GET /api/automations` · `POST /api/automations/{id}/dry-run|enable|disable` | Automatisations : état, journal, dry-run | P2-T11/T12 |
|
||||
| `GET/POST /api/reminders` · `POST /api/reminders/{id}/snooze|cancel|ack` | Rappels (miroir des outils) | P2-T08/T09 |
|
||||
| `GET /api/memory/consolidation` | Derniers runs de consolidation et leurs compteurs | P2-T06 |
|
||||
|
||||
## 7. Comportements détaillés
|
||||
|
||||
### 7.1 Une tâche planifiée se relit comme un contrat
|
||||
|
||||
L'UI des tâches planifiées affiche pour chaque tâche `agent_prompt` : le prompt, la whitelist effective, les plafonds, le dernier run (lien) et son résultat. Ce qui n'est pas affiché n'est pas autorisé : la whitelist par défaut ne s'étend que par `allowed_extra_tools` explicite, tracé dans l'audit.
|
||||
|
||||
### 7.2 Rappel = tâche + livraison
|
||||
|
||||
Il n'y a pas de second store de rappels : un rappel est une tâche de l'index portant `ctx: rappel` ou créée par `reminder_create`, à laquelle le scheduler rattache une **livraison** (canaux, acquittement, snooze, escalade). `Rappels.md` est une vue générée de l'index. Compléter la tâche acquitte le rappel ; snoozer décale la tâche (historique conservé).
|
||||
|
||||
### 7.3 Le dry-run est un droit, pas une option cachée
|
||||
|
||||
Toute automatisation peut être dry-runée avant activation **et** à tout moment ensuite, sur une fenêtre choisie. Le résultat du dry-run fait partie de son journal : une automatisation jamais dry-runée porte un badge « jamais testée » dans les Configurations.
|
||||
|
||||
### 7.4 Consolidation et vie privée
|
||||
|
||||
La consolidation ne lit que le vault et les conversations de l'utilisateur propriétaire (jamais inter-utilisateurs) ; les dossiers sensibles configurables forcent `pending` quelle que soit la confiance extraite ; les compteurs de validation (faits acceptés/rejetés) alimentent la métrique de dégradation du consolidateur (#212 : un taux de rejet en hausse durable est un signal, affiché au centre de contrôle).
|
||||
|
||||
## 8. Tests et golden tasks
|
||||
|
||||
- Unitaires : whitelist `agent_prompt` (tentatives d'évasion), watermark de consolidation, gardes d'automatisation (fréquence, dédup, profondeur), cron + `misfire` par horloge simulée, séries d'habitudes.
|
||||
- Intégration : nuit de consolidation complète sur fixture ; briefing après redémarrage ; rappel critique avec escalade ; automatisation de veille avec changement de page simulé.
|
||||
- Golden tasks de la phase : **G1** (rappel à 3 semaines), **G5** (secret jamais extrait, fait santé `pending`), **G8** (`frequence_max`) — définitions au parent §16.1.
|
||||
|
||||
## 9. Critères d'acceptation de la phase
|
||||
|
||||
1. Le briefing du matin paraît à l'heure, cite ses sources, et un redémarrage du conteneur ne produit ni doublon ni silence.
|
||||
2. Une nuit de consolidation produit des faits et des cartes traçables, respecte son budget dur, et ne perd aucune édition humaine concurrente.
|
||||
3. « Dans 3 semaines » devient un rappel livré à la bonne date ; le snooze répété est compté et visible.
|
||||
4. Une automatisation se dry-rune sans effet, s'active, respecte sa `frequence_max`, et se suspend proprement après 3 échecs.
|
||||
5. Aucune notification de la phase ne sort sans pourquoi ni lien, et le budget quotidien est tenu et affiché.
|
||||
|
||||
## 10. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| `agent_prompt` devient un cheval de Troie de périmètre | Whitelist par tâche affichée comme contrat (§7.1) ; toute extension est un événement d'audit ; DANGEROUS structurellement absent des whitelists |
|
||||
| La consolidation dégrade la mémoire en silence | Compteurs de validation suivis (#212) ; bouton « relire le Journal » dans Aujourd'hui ; possibilité de suspendre la consolidation sans perdre les faits |
|
||||
| Les automatisations notifient trop (mort par mille alertes) | Budget d'interruption dès cette phase (P2-T17), sévérité `info` rangée en revue, `frequence_max` obligatoire |
|
||||
| Dérive horaire (DST) sur rappels et rituels | Ancrage heure murale partout ; tests d'horloge sur les deux changements d'heure de `America/Toronto` |
|
||||
|
||||
## 11. Ordre d'exécution
|
||||
|
||||
```
|
||||
P2-T01 → T02 → T03 (#200 — le briefing rebranche la tâche intégrée de la phase 1)
|
||||
P2-T04 → T05 → T06 → T07 (#194 — après T01 : la consolidation est une tâche agent_prompt)
|
||||
P2-T13 (scheduler amendé, tôt : il sert T08 et T11)
|
||||
P2-T08 → T09 (#199)
|
||||
P2-T10 → T11 → T12 → T14 (#208 — T14 après T09 pour les rappels contextuels)
|
||||
P2-T15 → T16 (#214 — après T03, T11 et le plan de la phase 1)
|
||||
P2-T17 (budget — avant T09/T14 en pratique) → P2-T18 (clôture)
|
||||
```
|
||||
|
||||
## 12. Définition de « terminé »
|
||||
|
||||
DoD du document de phase 0 §12, inchangée, plus : les golden tasks G1, G5 et G8 vertes en CI ; chaque tâche planifiée et chaque automatisation livrée affiche son contrat (capacités, plafonds, budget) dans l'UI.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 3 : Le monde (connecteurs, synchronisation, client MCP)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **3 sur 5** — l'assistant se branche sur les services de Bruno |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §11 (connecteurs, sync, replis, envoi encadré, client MCP), §9.6 (candidats mémoire), §15 (politiques) |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Prêt à implémenter |
|
||||
| Tranches | **#195** (cadre connecteurs + moteur de sync), **#196** (Google Calendar + Gmail + replis zéro-OAuth), **#197** (Microsoft Teams/Outlook), **#198** (Discord lecture + Notion + Drive), **#207** (ObsiGate client MCP) |
|
||||
| Effort estimé | 17–23 jours |
|
||||
| Prérequis | Phase 0 (politiques #213 — provenance et règles d'envoi ; runs #206), phase 1 (plan et tâches qui consomment l'agenda), phase 2 (bus d'événements #208 qui consomme la sync) |
|
||||
| Dépendances | #195 ← — ; #196, #197 ← #195, #213 ; #198 ← #195 ; #207 ← #195, #213 |
|
||||
| Débloque | Phase 4 (engagements détectés dans les courriels en mode candidats, préparation de réunions sur agenda réel), phase 5 (packs Communication/Voyages complets) |
|
||||
|
||||
> **Résultat visible à la fin de cette phase.** Le plan du jour voit le **vrai agenda** et les vrais courriels. Les connecteurs Google, Microsoft, Discord, Notion et Drive sont installés via le cadre unifié « Sources connectées » : OAuth2 + PKCE quand il le faut, mais aussi des **replis sans aucun projet cloud** (iCal, CalDAV, IMAP) qui débloquent l'agenda et le courriel dès l'installation. Un moteur de **synchronisation incrémentale** maintient un état local frais que consomment le plan, les rappels et les automatisations (événements `connector.object.*` enfin émis). L'assistant cherche et lit le courriel, crée des brouillons, et **envoie** — uniquement sur approbation renforcée du texte final exact, jamais depuis une tâche planifiée. Enfin, ObsiGate devient **client MCP** : les serveurs MCP externes de Bruno entrent dans le même registre, sous les mêmes politiques.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Périmètre](#2-périmètre)
|
||||
3. [État d'entrée](#3-état-dentrée)
|
||||
4. [Modèle de données](#4-modèle-de-données)
|
||||
5. [Tickets de travail](#5-tickets-de-travail)
|
||||
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
|
||||
7. [Comportements détaillés](#7-comportements-détaillés)
|
||||
8. [Tests et golden tasks](#8-tests-et-golden-tasks)
|
||||
9. [Critères d'acceptation de la phase](#9-critères-dacceptation-de-la-phase)
|
||||
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
|
||||
11. [Ordre d'exécution](#11-ordre-dexécution)
|
||||
12. [Définition de « terminé »](#12-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **la prise de notes reste le vault ; les connecteurs nourrissent et exécutent, ils ne remplacent rien** (hérité de la V2, conservé). La phase ajoute deux corollaires propres à la V2.1 :
|
||||
|
||||
- **Un état frais ou un aveu d'état périmé.** Le plan, les déclencheurs et les réponses s'appuient sur le cache synchronisé ; si la sync a échoué, l'assistant le dit (« agenda non synchronisé depuis 2 h ») au lieu de planifier sur du vide.
|
||||
- **L'écriture externe est un privilège d'exécution, pas une capacité de fond.** Créer un événement, un brouillon ou envoyer suit les niveaux d'autonomie et la politique de provenance (phase 0) : rien d'externe ne part d'un contenu lu sans validation humaine du résultat final.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### 2.1 Inclus
|
||||
|
||||
- #195 : cadre `backend/connectors/` (interface, registre, installations, secrets 0600/env, OAuth2 code + PKCE, refresh transparent, retry/backoff, rate-limit par connecteur, audit de chaque `call()`) ; migration des sources Gitea/GitHub existantes sous le cadre (même UX) ; UI « Sources connectées » étendue (statut, scopes, test, journal d'appels, budget) ; **moteur de sync incrémentale** (collections déclarées, curseurs, webhooks fournisseurs quand disponibles sinon polling adaptatif, cache d'objets normalisés, émission des événements bus, page santé des syncs).
|
||||
- #196 : Google Calendar (lire/écrire) et Gmail (chercher/lire/brouillon/**envoyer** selon §7.3) sur le même consentement ; **replis zéro-OAuth** : iCal (lecture), CalDAV (lecture/écriture), IMAP (lecture courriel) ; guide d'installation Google pas-à-pas FR/EN.
|
||||
- #197 : Microsoft Graph — Teams (lire les salons choisis, envoyer en WRITE confirmé) et Outlook (courriel et calendriers, envoi selon §7.3) sur le même jeton Entra ID.
|
||||
- #198 : Discord en lecture par bot REST (pas de gateway en v1) en plus du webhook de sortie existant ; Notion (rechercher/lire/appendre, sync vers l'index de recherche) ; Drive (rechercher/lire, import vers le vault).
|
||||
- #207 : client MCP (serveurs déclarés par Bruno ; outils préfixés `mcp.<serveur>.*` dans le registre ; niveau de confiance par serveur ; empreinte de schéma et suspension en cas de changement).
|
||||
|
||||
### 2.2 Exclu (phases ultérieures)
|
||||
|
||||
- La **détection d'engagements dans les courriels** en production (#211, phase 4) : la phase livre le réglage candidats mémoire (§9.6 du parent, défaut `off`) et le pipeline ; l'activation métier est en phase 4.
|
||||
- Les packs qui consomment ces connecteurs au-delà du socle (Voyages, Communication avancée — phase 5).
|
||||
- Slack (option future, inchangé depuis la V2), l'envoi SMTP direct (l'envoi passe par les APIs Google/Microsoft), toute écriture Discord autre que le webhook existant.
|
||||
- La visioconférence et l'enregistrement de réunions : phase 4 (#209) pour la préparation et les comptes rendus, sur l'agenda livré ici.
|
||||
|
||||
## 3. État d'entrée
|
||||
|
||||
| Élément | Usage en phase 3 |
|
||||
|---|---|
|
||||
| Sources connectées Gitea/GitHub (#92/#103), `connected.py`, `secrets.py` | Précédent à généraliser ; migration sans perte d'UX |
|
||||
| Moteur de politiques + provenance (#213, phase 0) | Toute donnée connecteur entre avec `connector:<id>` ; les règles d'envoi et de sensibilité s'appliquent telles quelles |
|
||||
| Runs et approbation renforcée (phase 0/1) | Envois et écritures externes sont des steps de runs, approuvés dans la file unifiée |
|
||||
| Plan, tâches, automatisations (phases 1–2) | Consommateurs de l'agenda synchronisé et des événements `connector.object.*` |
|
||||
| Scheduler amendé (phase 2) | Les jobs de sync sont des tâches planifiées système (visibles, journalisées) |
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
```sql
|
||||
-- data/connectors_cache.sqlite3 (reconstructible par re-sync complète)
|
||||
CREATE TABLE sync_state (
|
||||
user_id TEXT NOT NULL, connector_id TEXT NOT NULL, collection TEXT NOT NULL,
|
||||
cursor TEXT, -- token/page/date du fournisseur
|
||||
last_sync_at TEXT, last_error TEXT, status TEXT NOT NULL DEFAULT 'ok',
|
||||
PRIMARY KEY (user_id, connector_id, collection)
|
||||
);
|
||||
CREATE TABLE synced_objects (
|
||||
user_id TEXT NOT NULL, connector_id TEXT NOT NULL,
|
||||
external_id TEXT NOT NULL, type TEXT NOT NULL, -- event|message|chat|page|file
|
||||
title TEXT, starts_at TEXT, ends_at TEXT,
|
||||
participants_json TEXT, excerpt TEXT, -- extrait borné, JAMAIS le corps complet
|
||||
link TEXT, updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, connector_id, external_id)
|
||||
);
|
||||
```
|
||||
|
||||
Installations et secrets : fichiers existants du patron V2 (`data/connectors.json` pour le public, `data/connector_secrets.json` 0600 ou variables `OBSIGATE_CONNECTOR_SECRET_<ID>` ; jetons OAuth chiffrés avec `data/secret.key`). Les collections synchronisables et leurs fenêtres (agenda ±30 j ; courriel : dossiers choisis, 14 j glissants ; Teams : salons choisis) sont déclarées par chaque connecteur, réglables dans l'UI.
|
||||
|
||||
## 5. Tickets de travail
|
||||
|
||||
*Convention : chaque ticket est indépendamment testable ; « Fichiers » donne les points d'ancrage d'après la V2.1 — à confirmer contre le code réel au démarrage du ticket. Les fournisseurs sont développés et testés contre des **simulateurs** (le harnais golden de la phase 0), les appels réels étant vérifiés en recette manuelle documentée par connecteur.*
|
||||
|
||||
### Cadre & sync — #195
|
||||
|
||||
**P3-T01 — Interface connecteur et registre.**
|
||||
Fichiers : `backend/connectors/base.py`, `backend/connectors/registry.py`, `backend/connectors/oauth.py`.
|
||||
Travail : `ConnectorInterface` (id, label, URL de doc, scopes, capabilities nommées, collections synchronisables) ; installation/désinstallation ; flux OAuth2 code + PKCE générique (endpoints d'allowlist codée par fournisseur) avec redirect `/api/connectors/{id}/callback`, échange + stockage du refresh token chiffré, refresh anticipé et 1 retry sur 401 dans `call()` ; auth par token statique pour les connecteurs qui s'y prêtent ; timeouts httpx 15 s, retry backoff, rate-limit par connecteur, cache GET court (TTL 5 min) pour le contexte de chat.
|
||||
Acceptation : un connecteur de test (fixture OAuth complète) s'installe, se teste, se rafraîchit et se désinstalle de bout en bout ; aucun secret n'apparaît dans les logs ni les retours d'outils (redaction vérifiée).
|
||||
|
||||
**P3-T02 — UI Sources connectées et migration Gitea/GitHub.**
|
||||
Fichiers : `backend/routers/connectors.py`, page Configurations.
|
||||
Travail : cartes par connecteur (statut, scopes accordés, boutons Connecter/Tester/Déconnecter, journal des derniers appels depuis l'audit, budget) ; migration interne de `connected.py` : Gitea/GitHub deviennent deux connecteurs du cadre, configurations existantes relues sans ressaisie ; les outils historiques gardent leurs noms.
|
||||
Acceptation : une installation avec Gitea/GitHub configurés « à l'ancienne » fonctionne après migration sans intervention ; la page montre les appels récents des deux sources.
|
||||
|
||||
**P3-T03 — Moteur de synchronisation incrémentale.**
|
||||
Fichiers : `backend/connectors/sync.py`, jobs scheduler système.
|
||||
Travail : pour chaque installation et collection : sync initiale bornée puis incrémentale par curseur ; réception webhook fournisseur quand le connecteur le déclare (Google/Microsoft), sinon polling adaptatif (défaut 15 min, ralenti la nuit, accéléré à l'approche d'un événement) ; normalisation des objets (§4) ; émission `connector.object.created|updated` sur le bus de la phase 2 ; erreurs → `last_error` + statut, jamais d'exception silencieuse ; re-sync complète manuelle depuis l'UI.
|
||||
Acceptation : un événement créé côté fournisseur (simulateur) apparaît dans le cache et émet l'événement bus en moins d'un cycle de polling ; la suppression côté fournisseur retire l'objet du cache (pas de fantôme dans le plan).
|
||||
|
||||
**P3-T04 — Santé des connecteurs.**
|
||||
Travail : agrégation par connecteur (dernière sync, retard, taux d'erreur, quota estimé) exposée dans la carte et au centre de contrôle ; signal de dégradation consommé par le plan et le briefing (le consommateur affiche « données périmées » avec l'âge) ; un connecteur déconnecté retire ses outils du registre présenté au modèle (`list_tools` filtré par capabilities actives, comme le prévoit la V2 pour Tauri).
|
||||
Acceptation : couper la sync (simulateur en échec) fait apparaître l'âge des données dans le plan du jour suivant, sans bloquer le plan.
|
||||
|
||||
### Google — #196
|
||||
|
||||
**P3-T05 — Calendar : lecture et écriture.**
|
||||
Travail : capabilities `gcal.list_events` (READ), `gcal.get_event` (READ), `gcal.create_event` (WRITE), `gcal.update_event` (WRITE), toutes avec post-conditions `verify` (relecture de l'événement) et inverses (suppression de l'événement créé) ; le plan du jour (phase 1) bascule sa source d'agenda sur le cache synchronisé quand le connecteur est actif ; les blocs validés du plan peuvent être écrits dans l'agenda (option « plan → agenda », désactivée par défaut).
|
||||
Acceptation : « bloque 2 h jeudi pour la présentation » crée l'événement vérifié, visible au plan ; le désactiver (déconnexion) rend le plan à sa source vault sans erreur.
|
||||
|
||||
**P3-T06 — Gmail : chercher, lire, brouillonner.**
|
||||
Travail : `gmail.search_messages` (syntaxe du fournisseur), `gmail.read_message`, `gmail.create_draft` (WRITE, inverse = suppression du brouillon) ; citation systématique dans les réponses (expéditeur, date, lien) ; le contenu lu porte la provenance `connector:gmail` et ne peut pas, seul, justifier une action externe (politique phase 0 — test dédié).
|
||||
Acceptation : « ai-je une réponse du dentiste ? » répond depuis le cache + lecture à la demande, avec citation ; aucun fait mémoire n'est créé depuis un courriel sans passer par le mode candidats (§9.6, défaut `off`).
|
||||
|
||||
**P3-T07 — Envoi encadré.**
|
||||
Travail : `gmail.send` (DANGEROUS) selon le parent §11.4 : brouillon d'abord (T06), carte d'approbation **renforcée** sur le texte final exact (destinataires, objet, corps ; toute modification régénère la carte), interdiction structurelle depuis tâches planifiées et automatisations (la politique refuse avant même la carte), journalisation en step de run avec inverse = néant affiché.
|
||||
Acceptation : les trois refus sont prouvés par tests (envoi en tâche planifiée ; envoi dont le corps est une copie d'un courriel reçu sans reformulation validée ; envoi après modification du brouillon sans nouvelle carte) ; un envoi approuvé part une fois (idempotence).
|
||||
|
||||
**P3-T08 — Replis zéro-OAuth : iCal, CalDAV, IMAP.**
|
||||
Fichiers : `backend/connectors/ical.py`, `caldav.py`, `imap.py`.
|
||||
Travail : iCal par URL secrète (lecture, sync par le moteur T03) ; CalDAV (lecture + création/màj d'événements, mot de passe d'application comme secret) ; IMAP (lecture : recherche et lecture, collections = dossiers choisis) ; ces connecteurs apparaissent dans la même matrice et la même UI, avec leurs limites affichées (« lecture seule » pour iCal/IMAP).
|
||||
Acceptation : un agenda iCloud/Nextcloud de test est lisible dans le plan sans aucun projet Google/Microsoft ; un compte IMAP de test répond à `mail.search` en lecture seule (toute tentative d'écriture est absente du registre, pas seulement refusée).
|
||||
|
||||
### Microsoft — #197
|
||||
|
||||
**P3-T09 — Graph : socle et Teams.**
|
||||
Travail : client Graph minimal (auth Entra ID via le cadre OAuth, pagination, erreurs typées) ; `teams.list_chats` (READ, salons choisis), `teams.read_messages` (READ), `teams.send_message` (WRITE avec confirmation affichant le salon et le texte exact — même régime que l'envoi courriel, adapté : pas de brouillon côté Teams, la carte tient lieu de relecture).
|
||||
Acceptation : lire un salon et y répondre via carte fonctionne sur tenant de test ; un salon non choisi n'est ni synchronisé ni lisible par l'outil.
|
||||
|
||||
**P3-T10 — Outlook : courriel et calendriers.**
|
||||
Travail : `outlook.search_messages`, `outlook.read_message`, `outlook.create_draft`, `outlook.send` (même ticket de politique que P3-T07 — factoriser, ne pas dupliquer la logique d'approbation), `outlook.list_events` alimentant le cache d'agenda au même titre que gcal (le plan fusionne les calendriers actifs en signalant la source de chaque événement).
|
||||
Acceptation : un plan peut mélanger événements Google et Outlook sans doublon quand le même événement existe des deux côtés (dédup par titre+horaires signalée en carte, jamais de fusion silencieuse).
|
||||
|
||||
### Discord, Notion, Drive — #198
|
||||
|
||||
**P3-T11 — Discord en lecture.**
|
||||
Travail : bot token (secret) ; `discord.list_messages` et `discord.search` en REST sur les salons choisis ; le webhook de sortie existant (#168) devient la capability `discord.send` du connecteur (WRITE via notify, pas de doublon de code) ; pas de gateway websocket (limite affichée : pas de temps réel, sync par polling).
|
||||
Acceptation : la recherche dans un salon de test retrouve un message cité ; les salons non choisis sont inaccessibles aux outils.
|
||||
|
||||
**P3-T12 — Notion : lire, appendre, synchroniser la recherche.**
|
||||
Travail : API key (token interne) ; `notion.search`, `notion.read_page`, `notion.append_blocks` (WRITE confirmé) ; les pages choisies sont synchronisées vers l'**index de recherche** d'ObsiGate (elles deviennent trouvables par la recherche et le recall, marquées `connector:notion` — jamais copiées dans le vault sans demande explicite d'import).
|
||||
Acceptation : « cherche dans Notion et dans mes notes » répond des deux sources distinguées ; un append est vérifié par relecture.
|
||||
|
||||
**P3-T13 — Drive : chercher, lire, importer.**
|
||||
Travail : `drive.search`, `drive.read_file` (Docs/Sheets convertis en texte/markdown via l'API) ; import explicite vers le vault (`drive.read_file` → proposition de note, propose/apply existant) rattaché au backlog d'import ; lecture seule stricte (aucune écriture Drive cette phase).
|
||||
Acceptation : importer un document Drive crée une note du vault citant sa source ; l'original Drive n'est jamais modifié.
|
||||
|
||||
### Client MCP — #207
|
||||
|
||||
**P3-T14 — Branchement de serveurs MCP externes.**
|
||||
Fichiers : `backend/connectors/mcp_client.py`.
|
||||
Travail : déclaration de serveurs (URL/transport, authentification par secret) ; découverte des outils à la connexion ; enregistrement dynamique préfixé `mcp.<serveur>.*` avec risque déclaré par le serveur **plafonné** par la politique (un outil déclaré READ par le serveur mais à effets inconnus démarre au niveau d'autonomie 0–1) ; les contenus MCP portent la provenance `mcp:<serveur>` ; aucun contenu `sensitivity ∈ {private, sensitive}` n'est transmissible à un outil MCP externe (politique phase 0, test dédié).
|
||||
Acceptation : un serveur MCP de test expose un outil utilisable en conversation au niveau 1 ; tenter de lui passer un fait `sensitive` est refusé et journalisé.
|
||||
|
||||
**P3-T15 — Empreintes de schéma et suspension.**
|
||||
Travail : empreinte (hash canonique du schéma de chaque outil) stockée à la validation du serveur ; à chaque session, comparaison : un schéma modifié suspend l'outil (retiré du registre présenté au modèle) et crée une carte de revalidation montrant le diff de schéma ; la revalidation est manuelle, par serveur.
|
||||
Acceptation : modifier le schéma d'un outil du serveur de test suspend exactement cet outil, pas les autres, et l'assistant explique la suspension au lieu d'échouer en silence.
|
||||
|
||||
### Finitions
|
||||
|
||||
**P3-T16 — Guides d'installation des fournisseurs.**
|
||||
Travail : `docs/GUIDES/CONNECTEURS_GOOGLE_MS.md` pas-à-pas FR/EN (création des applications Google Cloud et Entra ID, écrans de consentement, URI de redirection) + section replis zéro-OAuth (où trouver l'URL iCal, créer un mot de passe d'application) ; fiches `docs/features/` pour #195–#198 et #207 ; clés i18n des Sources connectées et des cartes d'envoi.
|
||||
Acceptation : un lecteur suivant le guide seul aboutit à un connecteur Google testé vert ; DoD du §12 pour le reste.
|
||||
|
||||
## 6. API livrée par la phase
|
||||
|
||||
| Route | Rôle | Ticket |
|
||||
|---|---|---|
|
||||
| `GET /api/connectors` · `POST /api/connectors/{id}/install|test|disconnect` | Installations et statut | P3-T01/T02 |
|
||||
| `GET /api/connectors/{id}/authorize` · `GET /api/connectors/{id}/callback` | Flux OAuth2 + PKCE | P3-T01 |
|
||||
| `GET /api/connectors/{id}/health` · `POST /api/connectors/{id}/sync` | Santé et re-sync manuelle | P3-T03/T04 |
|
||||
| `GET/POST /api/connectors/mcp-servers` · `POST /api/connectors/mcp-servers/{id}/revalidate` | Serveurs MCP externes et revalidation | P3-T14/T15 |
|
||||
|
||||
Les capabilities connecteurs sont des outils `@tool` ordinaires (donc exposés au serveur MCP d'ObsiGate sous les mêmes politiques) ; il n'y a pas d'API REST par capability.
|
||||
|
||||
## 7. Comportements détaillés
|
||||
|
||||
### 7.1 Ce que la sync ne fait jamais
|
||||
|
||||
Le corps complet d'un courriel n'est **jamais** synchronisé (extrait borné seulement) ; rien de synchronisé n'entre dans le vault ni dans la mémoire sans le pipeline candidats (§9.6, défaut `off`) ; désinstaller un connecteur purge son cache et ses curseurs (les secrets sont retirés, l'audit conserve la trace des appels passés sans contenu).
|
||||
|
||||
### 7.2 Fuseaux et calendriers multiples
|
||||
|
||||
Les événements synchronisés conservent leur fuseau d'origine ; le plan les projette dans `OBSIGATE_TZ` en affichant l'heure d'origine quand elle diffère (voyage). Un événement « toute la journée » reste ancré à sa date, jamais converti en minuit UTC décalé.
|
||||
|
||||
### 7.3 L'envoi, en une phrase
|
||||
|
||||
**Brouillon toujours, envoi jamais sans le texte final sous les yeux, et jamais en automatique** : ces trois règles sont codées dans la politique (phase 0) et testées en P3-T07/T10 ; le réglage d'activation de l'envoi (décision ouverte n° 2 du parent) peut le désactiver par connecteur sans toucher au code.
|
||||
|
||||
### 7.4 Tauri et hors-ligne
|
||||
|
||||
Sans token installé, les capabilities sont absentes du registre présenté au modèle (pas d'outils fantômes) ; hors-ligne, l'assistant répond depuis le cache synchronisé en annonçant son âge ; aucune tentative réseau n'a lieu au démarrage (P8 du parent).
|
||||
|
||||
## 8. Tests et golden tasks
|
||||
|
||||
- Simulateurs : chaque fournisseur a un double HTTP (réponses figées + scénarios d'erreur : 401, 429, pagination, webhook) utilisé en CI ; aucun test CI ne dépend d'un vrai compte.
|
||||
- Recette manuelle : par connecteur, un pas-à-pas de vérification sur compte réel, archivé dans la fiche de tranche (captures expurgées).
|
||||
- Golden tasks nouvelles de la phase : **G9** — « événement créé côté fournisseur → présent au plan, et plan honnête si la sync tombe » ; **G10** — « envoi refusé en tâche planifiée, accepté en interactif sur texte exact » ; **G11** — « schéma d'outil MCP modifié → outil suspendu, autres intacts ». (Numérotation à la suite de G1–G8 du parent.)
|
||||
- Non-régression : les sources Gitea/GitHub et les canaux de notification existants fonctionnent à l'identique après migration au cadre.
|
||||
|
||||
## 9. Critères d'acceptation de la phase
|
||||
|
||||
1. Google **ou** un repli zéro-OAuth suffit à alimenter le plan en agenda réel ; la fraîcheur des données est affichée, jamais présumée.
|
||||
2. Une recherche de courriel répond avec citation ; un brouillon se crée ; un envoi n'a lieu que sur approbation renforcée du texte final, et jamais en planifié.
|
||||
3. Désinstaller un connecteur retire ses outils, purge son cache et ses secrets, et dégrade proprement les consommateurs (plan, briefing).
|
||||
4. Un serveur MCP externe fonctionne sous politique ; un changement de schéma suspend l'outil concerné avec une carte de revalidation.
|
||||
5. G9, G10 et G11 sont verts en CI sur simulateurs.
|
||||
|
||||
## 10. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| Création des applications OAuth = friction d'installation | Les replis zéro-OAuth (P3-T08) débloquent l'usage avant tout projet cloud ; le guide T16 est testé par un lecteur naïf |
|
||||
| Quotas et changements d'API des fournisseurs | Tout passe par `call()` (rate-limit, backoff, audit) ; les échecs de sync dégradent, ne cassent pas (§7, P3-T04) |
|
||||
| Double agenda = double vérité | Le cache distingue les sources ; la dédup d'affichage est signalée, jamais silencieuse (P3-T10) |
|
||||
| Un connecteur compromis devient un canal d'injection | La provenance `connector:<id>` et les règles de la phase 0 s'appliquent à 100 % du contenu sync et lu ; les extraits synchronisés sont traités comme des données, jamais des instructions |
|
||||
|
||||
## 11. Ordre d'exécution
|
||||
|
||||
```
|
||||
P3-T01 → T02 → T03 → T04 (#195 — la sync avant les connecteurs qui s'en servent)
|
||||
P3-T05 → T06 → T07 ; T08 (#196 — T08 indépendant, parallélisable après T01)
|
||||
P3-T09 → T10 (#197 — T10 réutilise la politique d'envoi de T07)
|
||||
P3-T11 ; T12 ; T13 (#198 — parallélisables entre eux)
|
||||
P3-T14 → T15 (#207 — après T01 et la politique phase 0)
|
||||
P3-T16 (clôture)
|
||||
```
|
||||
|
||||
## 12. Définition de « terminé »
|
||||
|
||||
DoD du document de phase 0 §12, inchangée, plus : recette manuelle sur compte réel archivée par connecteur ; toute capability d'écriture déclare sa post-condition et son inverse (exigence du parent §21) ; les golden tasks G9–G11 vertes en CI.
|
||||
@@ -0,0 +1,204 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 4 : Les gens (personnes & engagements, voix & réunions, proactivité complète)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **4 sur 5** — l'assistant connaît les gens, tient les promesses, et ne parle qu'à bon escient |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §13.2 (personnes & engagements), §17 (voix & réunions), §9.6 (candidats connecteurs), §14.4 (budget d'interruption) |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Prêt à implémenter |
|
||||
| Tranches | **#211** (personnes & engagements), **#209** (voix, transcription & réunions), **#201** (proactivité / insights, forme complète) |
|
||||
| Effort estimé | 9–13 jours |
|
||||
| Prérequis | Phase 0 (entités #192, miroir `Personnes.md` en coquille #193), phase 1 (tâches #202, file d'approbations #203), phase 2 (consolidation #194, budget d'interruption, automatisations #208) ; phase 3 recommandée (l'agenda réel et les courriels candidats donnent leur pleine valeur à #211 et à la préparation de réunions) mais **non bloquante** : la phase fonctionne sur le vault seul, dégradée proprement |
|
||||
| Dépendances | #211 ← #192, #202 ; #209 ← #205, #211 ; #201 ← #194, #199, #202, #211 |
|
||||
| Débloque | Phase 5 (les packs Communication, Maison & admin et Voyages consomment personnes, engagements et cycle de réunion) |
|
||||
|
||||
> **Résultat visible à la fin de cette phase.** L'assistant sait **qui sont les gens** : entités personnes peuplées, notes dédiées quand Bruno le veut, anniversaires, dernières interactions sourcées. Il tient les **engagements dans les deux sens** — ce que Bruno a promis et ce qu'on lui doit — détectés en conversation ou dans les courriels (en candidats validables, jamais en silence), suivis en revue, avec des **relances rédigées en brouillon**. La **voix** devient une entrée de premier rang : dictée, notes vocales transcrites et triées, et cycle de réunion complet (préparation avec historique des participants → compte rendu structuré → actions datées avec responsables). Enfin, la **proactivité #201** arrive en forme complète : détecteurs branchés sur tâches, engagements, plans et habitudes, publiés dans `Insights.md` et consommés sous le budget d'interruption — utiles ou silencieux.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Périmètre](#2-périmètre)
|
||||
3. [État d'entrée](#3-état-dentrée)
|
||||
4. [Modèle de données](#4-modèle-de-données)
|
||||
5. [Tickets de travail](#5-tickets-de-travail)
|
||||
6. [API livrée par la phase](#6-api-livrée-par-la-phase)
|
||||
7. [Comportements détaillés](#7-comportements-détaillés)
|
||||
8. [Tests et golden tasks](#8-tests-et-golden-tasks)
|
||||
9. [Critères d'acceptation de la phase](#9-critères-dacceptation-de-la-phase)
|
||||
10. [Risques spécifiques et vigilance](#10-risques-spécifiques-et-vigilance)
|
||||
11. [Ordre d'exécution](#11-ordre-dexécution)
|
||||
12. [Définition de « terminé »](#12-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **les gens ne sont pas des données à aspirer, ce sont des relations à servir.** Chaque information sur une personne a une source que Bruno peut voir (il l'a dite, écrite, ou validée depuis un courriel candidat) ; rien n'est importé en masse, rien n'est inféré sur une personne à partir de signaux indirects, et les coordonnées restent `sensitivity='private'` — jamais transmises à un outil externe ou à un envoi sans que Bruno les ait lui-même fournies dans ce contexte.
|
||||
|
||||
Corollaires :
|
||||
|
||||
- **Un engagement n'existe que validé.** Détecter n'est pas enregistrer : toute détection (chat, courriel, compte rendu) produit une carte ; la création silencieuse est structurellement impossible (le pipeline n'a pas de chemin d'écriture sans approval de type `fait`/`triage`).
|
||||
- **La proactivité se juge à son taux d'acceptation.** Un insight refusé ou ignoré de façon répétée doit faire taire son détecteur (seuil relevé, puis suspension proposée) — la métrique décide, pas l'entêtement du système.
|
||||
|
||||
## 2. Périmètre
|
||||
|
||||
### 2.1 Inclus
|
||||
|
||||
- #211 : peuplement des entités personnes (miroir `Personnes.md` complet, notes dédiées optionnelles par personne), outils `people.*` ; table `commitments` (parent §13.2) et outils/API associés ; détection d'engagements en conversation (carte) et via les candidats connecteurs (§9.6 du parent — activation du réglage par connecteur, défaut `off`) ; suivi (revue du soir, échéance, relance rédigée en brouillon, clôture en un clic) ; anniversaires depuis les attributs des entités (recette de la phase 2 activée sur données réelles).
|
||||
- #209 : dictée dans les surfaces existantes ; enregistrement audio déposé (PWA, chat, pièce jointe) → transcription par le fournisseur **choisi explicitement** dans les Configurations (décision ouverte n° 3 du parent : défaut proposé = API du provider LLM déjà configuré ; aucun envoi audio par défaut sans ce choix) ; notes vocales comme items d'inbox `kind='voice'` (transcrites, résumées en une ligne, triées) ; cycle de réunion : note de préparation (participants = entités, historique sourcé, engagements ouverts avec eux, documents liés), capture optionnelle dans la note (dictée ou audio déposé), compte rendu structuré selon la procédure par défaut (résumé, décisions, actions) avec extraction des actions en **tâches datées à responsables nommés dans le texte uniquement**, décisions proposées à la mémoire en cartes, rappels de consentement à la capture.
|
||||
- #201 : détecteurs complets (projet sans prochaine action ; tâche reportée ≥ 3 fois ; engagement échu ; note « à faire » vieillissante > 21 j ; snooze ≥ 3 sur un rappel ; série d'habitude cassée ; inbox vieillissante) tournant en tâche planifiée légère et à la demande ; publication dans `Insights.md` (sections gérées, suppression = rejet du détecteur pour cet objet) ; suggestions injectées au compileur de contexte (bornées, sourcées) ; consommation stricte du budget d'interruption (phase 2) ; retour d'acceptation mesuré par détecteur avec auto-ajustement (§7.3).
|
||||
|
||||
### 2.2 Exclu (phases ultérieures / jamais)
|
||||
|
||||
- Tout **enrichissement externe** de profils (réseaux sociaux, bases tierces) : hors périmètre durable, pas seulement ajourné — contraire au principe du §1.
|
||||
- L'import en masse d'un carnet d'adresses : un connecteur contacts en lecture pourra être un pack de la phase 5, avec validation personne par personne ; rien ici ne l'anticipe en base.
|
||||
- L'enregistrement automatique de réunions (bot qui rejoint les appels) : la capture est toujours déclenchée par Bruno dans la note.
|
||||
- Le diagnostic ou le conseil santé à partir d'habitudes ou de personnes : hors périmètre (rappelé dans les procédures des packs concernés).
|
||||
|
||||
## 3. État d'entrée
|
||||
|
||||
| Élément | Usage en phase 4 |
|
||||
|---|---|
|
||||
| Entités/relations + `Personnes.md` coquille (phase 0) | Le modèle est là ; la phase peuple, outille et met en usage |
|
||||
| Consolidation (phase 2) | Émet déjà faits `open_loop` et candidats entités ; la phase branche la promotion en engagements et les cartes associées |
|
||||
| Candidats connecteurs (mécanisme phases 2–3, défaut `off`) | Première source réelle : les engagements et rendez-vous lus dans les courriels choisis |
|
||||
| Inbox et triage (phase 1) | Les notes vocales sont des items comme les autres ; les comptes rendus déposés se trient vers tâches/engagements |
|
||||
| Agenda réel (phase 3, optionnel) | La préparation de réunion part des événements ; sans connecteur, elle part des notes de réunion du vault |
|
||||
|
||||
## 4. Modèle de données
|
||||
|
||||
Table `commitments` du parent §13.2 (dans `data/assistant.sqlite3`) — reprise sans changement, avec la règle : `source` ∈ {`chat`, `consolidation`, `connector`, `meeting`, `manual`} et `source_ref` obligatoire sauf `manual`.
|
||||
|
||||
Attributs d'entité personne utilisés par la phase (dans `attrs_json`, schéma libre mais documenté) : `anniversaire` (MM-JJ ou AAAA-MM-JJ), `role` (texte libre de Bruno), `canal_prefere` (texte), `derniere_interaction` (calculé, jamais saisi par le modèle). Les coordonnées (courriel, téléphone) ne sont **pas** des attributs libres : champs dédiés `contact_json` chiffrés au repos quand le chiffrement #172 est actif, `sensitivity='private'` dans tous les cas, exclus des exports par défaut et du compileur de contexte sauf conversation concernant explicitement la personne.
|
||||
|
||||
Transcriptions : l'audio est un fichier temporaire de `data/` supprimé après transcription réussie (sauf réglage explicite « conserver les audios ») ; la transcription vit dans la note ou l'item d'inbox, jamais dans une base séparée.
|
||||
|
||||
## 5. Tickets de travail
|
||||
|
||||
*Convention : chaque ticket est indépendamment testable ; « Fichiers » donne les points d'ancrage d'après la V2.1 — à confirmer contre le code réel au démarrage du ticket.*
|
||||
|
||||
### Personnes & engagements — #211
|
||||
|
||||
**P4-T01 — Personnes : miroir complet et notes dédiées.**
|
||||
Fichiers : `backend/memory/mirror.py` (extension), `backend/tools/people.py`.
|
||||
Travail : `Personnes.md` peuplé (par personne : relations connues, anniversaire, dernières interactions sourcées avec dates, engagements ouverts la concernant) ; création optionnelle d'une note dédiée par personne (dossier configurable, créée uniquement sur demande explicite ou validation de carte) ; outils `people_list` (READ), `people_get` (READ — coordonnées masquées par défaut, révélées seulement en conversation la concernant), `people_note_add` (WRITE — ligne datée dans la note dédiée).
|
||||
Acceptation : aucune coordonnée n'apparaît dans `Personnes.md` (miroir = relations et contexte, pas carnet d'adresses) ; un test le vérifie sur fixture contenant des coordonnées.
|
||||
|
||||
**P4-T02 — Engagements : store, outils, API.**
|
||||
Fichiers : `backend/tools/people.py` (suite), `backend/routers/commitments.py` ou extension du routeur approvals/today.
|
||||
Travail : table `commitments` (§4) ; outils `commitment_list` (READ — ouverts, échus d'abord), `commitment_create` (WRITE via carte), `commitment_close` (WRITE — `done|dropped`, motif libre), `commitment_followup` (READ — état et dernière relance) ; section « Engagements » d'Aujourd'hui (échus en tête) et de la revue du soir ; clôture en un clic depuis la carte, Aujourd'hui ou la conversation.
|
||||
Acceptation : un engagement échu non clos apparaît à la revue du soir le jour J, puis en insight (#201) à J+2 s'il est toujours ouvert — pas avant, pas en notification dédiée (budget).
|
||||
|
||||
**P4-T03 — Détection en conversation et en consolidation.**
|
||||
Travail : en conversation, les formulations d'engagement (« je vais envoyer X à Marie vendredi », « Karim doit me rendre Y ») produisent une carte de création pré-remplie (direction, personne résolue ou proposée, échéance **uniquement si énoncée**) ; en consolidation, les faits `open_loop` validés sont proposés en promotion vers `commitments` (une carte, pas une conversion automatique) ; résolution de personne ambiguë = carte de choix (règle de la phase 0).
|
||||
Acceptation : sur un corpus de 20 formulations de test, aucune création sans carte et aucune échéance inventée ; une promotion refusée ne se représente pas au run suivant (mémoire de rejet).
|
||||
|
||||
**P4-T04 — Candidats connecteurs et relances.**
|
||||
Travail : activation du réglage `memoire: candidats` par connecteur (défaut `off`, UI Sources connectées) : les engagements détectés dans les collections synchronisées choisies deviennent des candidats `pending` sourcés (lien vers l'objet) dans la file unifiée ; relance : à l'échéance + délai configurable, proposition de brouillon de relance (prompt de l'annexe B.3 du parent) via `gmail.create_draft`/`outlook.create_draft` quand le connecteur existe, sinon texte proposé dans la carte ; l'envoi suit le régime de la phase 3 (§11.4 du parent), sans exception.
|
||||
Acceptation : avec le réglage `off`, zéro candidat produit (test) ; avec `on`, un courriel fixture « je vous enverrai le devis jeudi » produit exactement un candidat sourcé ; la relance n'est jamais envoyée par la tranche.
|
||||
|
||||
### Voix & réunions — #209
|
||||
|
||||
**P4-T05 — Transcription : fournisseur et surfaces.**
|
||||
Fichiers : `backend/tools/voice.py` (ou module `backend/voice/`), Configurations.
|
||||
Travail : réglage du fournisseur de transcription (choix explicite obligatoire avant le premier envoi audio ; choix proposés : API du provider LLM configuré disposant d'une offre voix, sinon aucun — pas de moteur ajouté au conteneur, P1) ; dictée navigateur branchée sur le champ de capture, le panneau et l'éditeur ; enregistrement et dépôt d'audio (PWA, chat, pièce jointe) → file de transcription (run visible) → texte ; suppression de l'audio après succès sauf réglage contraire ; durées et tailles plafonnées et affichées.
|
||||
Acceptation : sans fournisseur choisi, l'UI explique et ne transmet rien ; une transcription est un run journalisé avec son coût ; l'audio d'un test est absent de `data/` après traitement (réglage par défaut).
|
||||
|
||||
**P4-T06 — Notes vocales vers l'Inbox.**
|
||||
Travail : un audio déposé devient un item `kind='voice'` : transcription, résumé en une ligne (cheap model), puis triage ordinaire de la phase 1 (les dates relatives se résolvent à l'horodatage de l'**enregistrement**) ; la carte de triage montre la transcription complète repliable, jamais le seul résumé.
|
||||
Acceptation : « rappelle-moi demain d'appeler le dentiste » en note vocale produit une proposition de tâche à la bonne date ; une note vocale ambiguë reste un item texte, pas une action devinée.
|
||||
|
||||
**P4-T07 — Préparation de réunion.**
|
||||
Fichiers : skill `/reunion-prep`, outil de préparation (agrégateur READ).
|
||||
Travail : à partir d'un événement (phase 3) ou d'une note de réunion : participants résolus en entités, historique (notes liées, derniers comptes rendus avec eux), engagements ouverts les concernant (T02), documents rassemblés (liens), points probables **uniquement sourcés** (ordres du jour passés, tâches ouvertes du projet) ; sortie = note de préparation dans le vault, générée en run, relue par Bruno avant la réunion.
|
||||
Acceptation : la préparation d'une réunion fixture ne contient aucune affirmation sans source ; un participant inconnu est signalé comme tel, pas « enrichi ».
|
||||
|
||||
**P4-T08 — Compte rendu et extraction d'actions.**
|
||||
Travail : transcription (dictée en direct dans la note, ou audio déposé) → compte rendu structuré selon la procédure par défaut (modifiable, mémoire procédurale phase 0) : résumé, décisions, actions ; extraction selon le prompt de l'annexe B.2 du parent : responsable = personne **nommée dans le texte**, échéance = **énoncée**, le reste part en `a_clarifier` ; propositions en file unifiée : tâches (créées dans la note projet ou la note de réunion), décisions → faits mémoire `pending`, suivis → brouillons ; écran de capture avec rappel de consentement, et aucune capture ne peut être déclenchée par une automatisation (politique).
|
||||
Acceptation : le scénario G4-bis (transcription fixture avec 3 actions dont 1 sans date et 1 sans responsable) produit 3 propositions exactes : 1 tâche datée complète, 1 sans échéance, 1 en `a_clarifier` — aucune invention.
|
||||
|
||||
### Proactivité complète — #201
|
||||
|
||||
**P4-T09 — Détecteurs.**
|
||||
Fichiers : `backend/automations/detectors.py` (nouveau), tâche planifiée légère.
|
||||
Travail : les sept détecteurs du §2.1, déterministes sur les index (tâches, engagements, inbox, habitudes, notes) — seul le détecteur « note à faire vieillissante » utilise un balayage de contenu borné ; chaque détection produit un objet insight {détecteur, objet, preuve, proposition d'action} ; exécution nocturne (avec la consolidation, sans coût LLM sauf rédaction optionnelle) et à la demande (`/insights`).
|
||||
Acceptation : chaque détecteur a une fixture positive et une négative ; aucun détecteur n'émet deux fois pour le même objet et le même état (dédup par empreinte objet+état).
|
||||
|
||||
**P4-T10 — Publication et consommation des insights.**
|
||||
Travail : `Insights.md` en sections gérées (suppression d'un item = rejet pour cet objet, alimentant le compteur du détecteur) ; injection bornée au compileur de contexte (≤ 3, sourcés, seulement s'ils concernent le sujet de la conversation) ; actions proposées en un clic (créer la prochaine action du projet, clore ou relancer l'engagement, découper la tâche reportée) — toujours des cartes/runs ordinaires ; consommation du budget d'interruption : les insights ne notifient **jamais** directement, ils attendent le briefing ou Aujourd'hui (sévérité `info` par construction).
|
||||
Acceptation : une semaine de fixture produit des insights consultables sans produire une seule notification push d'insight.
|
||||
|
||||
**P4-T11 — Boucle d'acceptation des détecteurs.**
|
||||
Travail : compteurs par détecteur (émis, acceptés, rejetés, ignorés 3 fois) au centre de contrôle ; règles d'auto-ajustement : taux d'acceptation < 20 % sur 20 émissions → seuil du détecteur relevé automatiquement (journalisé) ; < 10 % ensuite → proposition de suspension en carte (jamais de suspension silencieuse) ; réactivation manuelle toujours possible.
|
||||
Acceptation : un détecteur fixture bruyant voit son seuil relevé après 20 émissions majoritairement rejetées, et l'événement est visible au centre de contrôle.
|
||||
|
||||
### Finitions
|
||||
|
||||
**P4-T12 — i18n, docs, guides, vie privée.**
|
||||
Travail : clés FR/EN (personnes, engagements, réunions, insights) ; fiches `docs/features/` pour #211, #209, #201 ; guide « Personnes, engagements et réunions » incluant la page des réglages de confidentialité (candidats connecteurs, fournisseur de transcription, conservation des audios) ; extension de l'export/effacement (parent §15.4) aux engagements et aux attributs personnes.
|
||||
Acceptation : l'effacement du domaine « personnes » supprime entités concernées, engagements et miroirs associés, avec trace d'audit ; DoD du §12 pour le reste.
|
||||
|
||||
## 6. API livrée par la phase
|
||||
|
||||
| Route | Rôle | Ticket |
|
||||
|---|---|---|
|
||||
| `GET /api/people` · `GET /api/people/{id}` | Personnes (coordonnées masquées par défaut) | P4-T01 |
|
||||
| `GET/POST /api/commitments` · `POST /api/commitments/{id}/close` | Engagements et clôture | P4-T02 |
|
||||
| `POST /api/voice/transcribe` | Transcription d'un audio déposé (run) | P4-T05 |
|
||||
| `POST /api/meetings/prepare` · `POST /api/meetings/minutes` | Préparation et compte rendu | P4-T07/T08 |
|
||||
| `GET /api/insights` · `POST /api/insights/{id}/act|dismiss` | Insights et leurs actions | P4-T10 |
|
||||
|
||||
Outils `@tool` correspondants : `people.*`, `commitment.*` (parent §9.7/§13.2), exposés au MCP comme les autres — avec la règle que `people_get` via MCP ne renvoie **jamais** les coordonnées (masquage renforcé hors conversation directe).
|
||||
|
||||
## 7. Comportements détaillés
|
||||
|
||||
### 7.1 Sans la phase 3, la phase vit quand même
|
||||
|
||||
Pas de courriel candidat (la détection reste conversation + consolidation), préparation de réunion depuis les notes du vault, anniversaires et engagements pleinement fonctionnels. L'UI signale les capacités absentes comme des réglages à activer, pas comme des erreurs.
|
||||
|
||||
### 7.2 Un engagement, deux directions, un seul objet
|
||||
|
||||
`je_dois` et `on_me_doit` partagent le même cycle de vie ; la revue du soir présente les deux listes séparément (ce que je dois faire demain / ce que j'attends), et une relance n'existe que pour `on_me_doit` — pour `je_dois`, l'action proposée est une tâche.
|
||||
|
||||
### 7.3 La proactivité a un éditeur : Bruno
|
||||
|
||||
Le taux d'acceptation par détecteur est la métrique directrice de la phase (suivie par #212) : elle est affichée, et les ajustements automatiques de seuil sont des événements journalisés et réversibles. Un détecteur suspendu peut être réactivé ; un détecteur supprimé d'`Insights.md` objet par objet apprend ce qui ne sert pas.
|
||||
|
||||
## 8. Tests et golden tasks
|
||||
|
||||
- Unitaires : détection d'engagements (corpus de formulations, aucune date inventée), masquage des coordonnées (miroir, export, MCP), dédup des insights, auto-ajustement des seuils.
|
||||
- Intégration : courriel candidat → engagement → échéance → brouillon de relance ; note vocale → tâche ; réunion complète prép → CR → tâches.
|
||||
- Golden tasks de la phase : **G12** — « engagement pris par courriel (réglage candidats activé) suivi jusqu'au brouillon de relance, jamais envoyé seul » ; **G13** — « compte rendu : 3 actions extraites sans invention (cf. P4-T08) » ; **G14** — « une semaine d'insights : zéro notification d'insight, taux d'acceptation mesuré ». (Suite de G1–G11.)
|
||||
|
||||
## 9. Critères d'acceptation de la phase
|
||||
|
||||
1. Un engagement créé en conversation ou validé depuis un courriel est suivi jusqu'à sa clôture ou sa relance rédigée — sans création silencieuse à aucun maillon.
|
||||
2. Aucune coordonnée de personne ne fuit : ni miroir, ni export par défaut, ni MCP, ni outil externe (tests dédiés verts).
|
||||
3. Une note vocale et un compte rendu produisent des tâches exactes (responsables et dates uniquement quand ils sont énoncés).
|
||||
4. Les insights sont utiles ou se taisent : publication sans notification directe, compteurs d'acceptation actifs, auto-ajustement prouvé sur fixture.
|
||||
5. La phase fonctionne, dégradée mais complète, sans aucun connecteur.
|
||||
|
||||
## 10. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| Sentiment de surveillance des tiers (réunions, courriels) | Consentement affiché à la capture, candidats toujours validés, aucune donnée tierce conservée hors du fait validé ; les personnes peuvent être « gelées » (entité exclue de toute détection) par un réglage par personne |
|
||||
| Détection d'engagements trop zélée (faux positifs en chaîne) | Cartes regroupées, mémoire de rejet par formulation, métrique d'acceptation du détecteur d'engagements suivie comme les autres |
|
||||
| Coût de transcription sur de longues réunions | Plafonds de durée affichés avant traitement, coût journalisé par run, fournisseur choisi et révocable |
|
||||
| Insights « bienveillants » mais inutiles qui érodent la confiance | La boucle d'acceptation (P4-T11) est un critère de phase, pas une amélioration future |
|
||||
|
||||
## 11. Ordre d'exécution
|
||||
|
||||
```
|
||||
P4-T01 → T02 → T03 → T04 (#211)
|
||||
P4-T05 → T06 ; T07 → T08 (#209 — T07 peut démarrer après T01/T02)
|
||||
P4-T09 → T10 → T11 (#201 — après T02 pour le détecteur d'engagements)
|
||||
P4-T12 (clôture)
|
||||
```
|
||||
|
||||
## 12. Définition de « terminé »
|
||||
|
||||
DoD du document de phase 0 §12, inchangée, plus : les golden tasks G12–G14 vertes en CI ; les tests de non-fuite des coordonnées (§9, critère 2) font partie de la suite permanente, pas d'un audit ponctuel.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Assistant V2.1 ObsiGate — Phase 5 : L'extension (packs de domaines et évaluation continue)
|
||||
|
||||
| Champ | Valeur |
|
||||
|---|---|
|
||||
| Phase | **5 sur 5** — optionnelle et étalée : le cœur (phases 0–4) est complet sans elle |
|
||||
| Document parent | `ASSISTANT-V2.1.md` — en particulier §13.4 (packs de domaines), §16 (évaluation & observabilité), §20 (risque « périmètre qui grandit sans fin ») |
|
||||
| Version | 1.0 — 9 octobre 2026 |
|
||||
| Auteur | Spark, pour Bruno |
|
||||
| Statut | Cadre prêt ; chaque chantier s'active séparément, à la demande de Bruno |
|
||||
| Tranches | **#210** (packs de domaines, chantiers A–G), **#212** (évaluation & observabilité — volet continu, chantier H) |
|
||||
| Effort estimé | 3–5 jours **par chantier de pack**, étalés ; chantier H : 2–3 jours puis continu |
|
||||
| Prérequis | Phases 0–4 terminées pour les chantiers qui consomment connecteurs, personnes et automatisations ; les chantiers A et F sont possibles dès la phase 2 |
|
||||
| Dépendances | #210 ← #208 (+ phase 3 pour B, C partiellement, E ; + phase 4 pour C et les relances) ; #212 ← amorce de la phase 0, puis toutes les phases |
|
||||
| Débloque | Rien : c'est la phase d'extension. Chaque chantier terminé enrichit l'assistant sans rien débloquer d'obligatoire |
|
||||
|
||||
> **Résultat visible à la fin de chaque chantier.** Un domaine de vie de plus est couvert de bout en bout par l'assistant — pas par un nouveau moteur, mais par l'assemblage curé de ce qui existe : des **recettes** (automatisations et plans), des **procédures** (la façon de Bruno de faire les choses, en mémoire), des **skills** et ses **golden tasks** propres. Un pack ne s'active qu'à la demande, se désactive sans laisser de trace dans le quotidien, et prouve son utilité par ses scénarios avant d'être livré. Le chantier H (#212) court en parallèle de toute la phase : le corpus de golden tasks grandit, les métriques du centre de contrôle deviennent des revues périodiques, et le routage de modèles s'affine sur données réelles.
|
||||
|
||||
---
|
||||
|
||||
## Table des matières
|
||||
|
||||
1. [Objectif et principe directeur](#1-objectif-et-principe-directeur)
|
||||
2. [Ce qu'est un pack — anatomie et gouvernance](#2-ce-quest-un-pack--anatomie-et-gouvernance)
|
||||
3. [État d'entrée](#3-état-dentrée)
|
||||
4. [Chantiers de packs — #210](#4-chantiers-de-packs--210)
|
||||
5. [Chantier H — Évaluation & observabilité continues — #212](#5-chantier-h--évaluation--observabilité-continues--212)
|
||||
6. [Critères d'acceptation de la phase](#6-critères-dacceptation-de-la-phase)
|
||||
7. [Risques spécifiques et vigilance](#7-risques-spécifiques-et-vigilance)
|
||||
8. [Ordre d'exécution suggéré](#8-ordre-dexécution-suggéré)
|
||||
9. [Définition de « terminé »](#9-définition-de--terminé-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objectif et principe directeur
|
||||
|
||||
Le principe : **« maximum de tâches » s'atteint par assemblage, pas par accumulation de moteurs.** Le cœur des phases 0–4 (mémoire, tâches, plan, exécution fiable, connecteurs, automatisations, personnes) est générique à dessein ; la valeur d'un domaine — voyages, argent, apprentissage — tient à ses recettes, ses procédures et ses critères de qualité, pas à du code nouveau. La phase 5 est donc organisée en **chantiers indépendants et optionnels**, sur le modèle de la phase d'extension des documents Agents & Skills : aucun chantier n'est un prérequis d'un autre (hors dépendances notées), chacun a ses critères de sortie, et la phase n'a pas de « fin » obligée.
|
||||
|
||||
Deux garde-fous contre la dispersion (risque nommé au parent §20) :
|
||||
|
||||
- **Un pack = un chantier, un chantier = une livraison.** Pas de pack « à moitié activé » : recettes, procédures, skills et golden tasks arrivent ensemble, ou le chantier n'est pas terminé.
|
||||
- **La désactivation est un non-événement.** Retirer un pack retire ses recettes actives et ses skills des menus ; les notes, tâches et faits créés restent (ce sont des données de Bruno, pas du pack).
|
||||
|
||||
## 2. Ce qu'est un pack — anatomie et gouvernance
|
||||
|
||||
| Composant | Forme | Exemple |
|
||||
|---|---|---|
|
||||
| Recettes | Automatisations et plans pré-remplis (phase 2), activables une à une | « Veille de prix hebdomadaire » |
|
||||
| Procédures | Faits `kind='procedure'` livrés avec le pack, **modifiables** (mémoire phase 0) | « Un dossier de voyage contient : itinéraire, documents, contacts, budget » |
|
||||
| Skills | Skills `/` existants ou nouveaux, dans le format actuel (`data/skills.json` et skills intégrés) | `/voyage-prep` |
|
||||
| Modèles de notes | Notes modèles dans le vault (listes, checklists, tableaux) | Checklist d'entretien maison par saison |
|
||||
| Golden tasks | Scénarios du domaine, ajoutés au corpus (chantier H) | 2–4 par pack, bloquants pour la livraison du chantier |
|
||||
| Réglages | Niveau d'autonomie par défaut proposé (jamais imposé), budgets propres éventuels | Pack Voyages : niveau 1 par défaut (lecture) |
|
||||
|
||||
Gouvernance : un registre simple des packs (installé/activé/désactivé) dans les Configurations, section Assistant ; l'activation affiche **tout** ce que le pack ajoutera (recettes avec leurs déclencheurs, procédures avec leur texte, skills) avant confirmation — même exigence de transparence que pour une automatisation importée (parent §15.1). Aucun pack ne peut élever un niveau d'autonomie ni créer une délégation : il *propose*, les mécanismes des phases 0–1 disposent.
|
||||
|
||||
## 3. État d'entrée
|
||||
|
||||
Tout ce que les phases 0–4 ont livré, et en particulier : le catalogue d'outils existants bien plus riche que le seul périmètre assistant (documents docx/xlsx/csv/pdf et outils tableurs complets — lecture, recherche, analyse de plages, édition — décrits dans les guides `ASSISTANT_IA_FORGE` et `RECHERCHE_PDF_EXCALIDRAW`), les skills d'apprentissage déjà intégrés (`/quiz`, `/reading-note`, `/qa-generator`…), la recherche et le Deep Research, `fetch_url`/`crawl_site`, les connecteurs Gitea/GitHub migrés au cadre (phase 3), les automatisations et le bus (phase 2), les personnes et engagements (phase 4). Plusieurs chantiers ci-dessous ne demandent **aucun nouvel outil** — c'est un critère de conception, pas un hasard.
|
||||
|
||||
## 4. Chantiers de packs — #210
|
||||
|
||||
*Convention : chaque chantier est décrit par son contenu (recettes/procédures/skills), ses dépendances propres, 1 exemple de golden task et ses critères de sortie spécifiques. L'effort type est de 3–5 jours, presque entièrement en contenu et en tests, pas en moteur.*
|
||||
|
||||
### Chantier A — Connaissance & veilles avancées
|
||||
|
||||
Contenu : recettes de veille multi-sources (page web + recherche périodique sur un sujet → note de diff dans le vault, insight seulement si changement matériel) ; synthèse de dossier à la demande (« tout ce que j'ai sur X, et ce qui a changé depuis <date> ») en skill assemblant recherche vault, Notion synchronisée (phase 3) et mémoire ; revue de lecture hebdomadaire depuis l'inbox (items `url` non triés > 7 j proposés en lot : lire/résumer/archiver).
|
||||
Dépendances : phases 0–2 (3 pour la source Notion).
|
||||
Golden task (exemple) : une page fixture modifiée entre deux veilles produit exactement une note de diff citant l'ancien et le nouveau contenu ; inchangée, elle ne produit rien.
|
||||
Sortie : les trois recettes fonctionnent sur fixtures ; aucun nouvel outil créé.
|
||||
|
||||
### Chantier B — Documents & données du quotidien
|
||||
|
||||
Contenu : procédures et recettes sur les outils **existants** (documents et tableurs) : classeur de budget personnel (modèle xlsx + recette mensuelle « importer un relevé csv déposé dans le vault → catégoriser selon la procédure de Bruno → mettre à jour le classeur → rapport d'écarts en note ») ; génération de documents récurrents (attestations, courriers types) depuis des modèles de notes et des faits mémoire ; extraction de tableaux de PDF du vault vers des notes.
|
||||
Dépendances : phases 0–2 ; relevés = fichiers déposés par Bruno (aucune connexion bancaire, voir chantier D).
|
||||
Golden task : un relevé fixture de 30 lignes est catégorisé selon la procédure, avec les lignes ambiguës en carte — jamais devinées.
|
||||
Sortie : le rapport mensuel complet est produisible par une recette dry-runée puis activée.
|
||||
|
||||
### Chantier C — Maison & administration personnelle
|
||||
|
||||
Contenu : modèle d'entretien récurrent (tâches RRULE par équipement, notes d'interventions liées) ; démarches suivies (renouvellements, garanties, assurances) comme engagements envers soi avec échéances et documents rassemblés ; listes vivantes (courses, cadeaux — alimentées par l'inbox et les personnes de la phase 4 : anniversaires → propositions) ; rappels saisonniers en recettes.
|
||||
Dépendances : phases 1, 2, 4 (anniversaires et cadeaux) ; phase 3 optionnelle (échéances lues dans les courriels candidats).
|
||||
Golden task : la garantie d'un équipement arrivant à échéance dans 30 j produit une tâche datée avec le document lié, sans notification directe (revue).
|
||||
Sortie : le dossier « Maison » du vault de test est entièrement tenu par les recettes du pack pendant un mois simulé.
|
||||
|
||||
### Chantier D — Argent en lecture seule
|
||||
|
||||
Contenu : **strictement** la lecture et l'analyse de données que Bruno dépose (relevés csv/xlsx, exports) : catégorisation (procédure du chantier B réutilisée), budgets par enveloppe en notes, alertes d'échéances de factures détectées dans les notes et les courriels candidats, synthèse mensuelle. Aucun accès bancaire direct, aucun paiement, aucune initiation de virement — ces exclusions sont **structurelles** (aucun outil du pack n'a de capability d'écriture externe) et rappelées dans la fiche du pack.
|
||||
Dépendances : chantier B ; phase 3 pour les candidats courriels.
|
||||
Golden task : une facture fixture échue non payée (selon les notes) apparaît en revue avec sa source ; une facture payée n'y apparaît pas.
|
||||
Sortie : la synthèse mensuelle d'un mois fixture est exacte au centime sur les lignes non ambiguës, et les ambiguës sont listées à part.
|
||||
|
||||
### Chantier E — Voyages
|
||||
|
||||
Contenu : procédure « dossier de voyage » (itinéraire, documents, contacts sur place, budget, contraintes) ; recettes : préparation (checklist générée depuis la procédure et les dates, documents rassemblés depuis le vault et Drive), rappels décalés par fuseau (le moteur RRULE/heure murale des phases 1–2, appliqué au fuseau de destination pour les rappels « sur place »), briefing de départ (agenda, documents, engagements en suspens pendant l'absence), retour (dépenses déposées → chantier D, photos/notes → dossier).
|
||||
Dépendances : phases 1–4 (agenda réel et Drive en phase 3 fortement recommandés).
|
||||
Golden task : un voyage fixture sur 2 fuseaux produit des rappels aux heures murales correctes dans chaque fuseau, affichés avec leur heure d'origine (règle de la phase 3 §7.2).
|
||||
Sortie : le dossier complet d'un voyage de test est produit par les recettes, sans intervention de code.
|
||||
|
||||
### Chantier F — Apprentissage
|
||||
|
||||
Contenu : assemblage des skills **existants** (`/quiz`, `/reading-note`, `/qa-generator`, `/summary-progressive`) avec les habitudes (phase 2) et la mémoire : parcours de lecture d'un dossier (ordre proposé, fiches générées dans le vault, quiz espacés en tâches récurrentes liées à l'objectif), revue d'apprentissage mensuelle (ce qui est su, mesuré par les quiz, pas déclaré).
|
||||
Dépendances : phases 0–2 seulement — chantier livrable tôt.
|
||||
Golden task : un chapitre fixture produit des fiches et un quiz ; les réponses du quiz mettent à jour le suivi sans toucher aux notes sources.
|
||||
Sortie : un parcours complet sur un dossier de test, de la première fiche à la revue mensuelle.
|
||||
|
||||
### Chantier G — Synchronisation de tâches externe (option conditionnelle)
|
||||
|
||||
Contenu : **seulement si** Bruno tranche la décision ouverte n° 4 du parent en faveur d'un gestionnaire externe (Todoist ou équivalent) : connecteur du cadre (phase 3) en synchronisation bidirectionnelle avec le vault **source de vérité** (règles du parent §11.5 : tâches synchronisées marquées `source='connector'` + id externe ; conflit = modification la plus récente gagne, journalisée et visible) ; la vue Aujourd'hui et le plan continuent de lire l'index local.
|
||||
Dépendances : phases 1, 3 ; décision de Bruno.
|
||||
Golden task : la même tâche modifiée des deux côtés pendant une coupure se résout selon la règle, avec le conflit visible dans le journal — jamais de perte silencieuse.
|
||||
Sortie : une semaine d'usage simulé sans divergence d'index ; la désactivation du connecteur laisse le vault complet et autonome.
|
||||
|
||||
## 5. Chantier H — Évaluation & observabilité continues — #212
|
||||
|
||||
Ce chantier n'est pas un pack : c'est le volet continu de #212, amorcé en phase 0 (harnais + G3/G6/G7) et enrichi par chaque phase (G1/G2/G4/G5/G8 en phase 2, G9–G11 en phase 3, G12–G14 en phase 4, puis les golden tasks des packs).
|
||||
|
||||
**P5-H1 — Corpus vivant.** Le corpus `tests/golden/` devient une suite de référence versionnée : tout scénario qui a un jour révélé un défaut réel y entre comme test de non-régression (règle : un bug d'assistant corrigé sans golden task ajouté n'est pas « terminé »). Revue du corpus à chaque livraison de chantier : scénarios obsolètes retirés explicitement, jamais en silence.
|
||||
|
||||
**P5-H2 — Métriques en revues.** Les compteurs du centre de contrôle (parent §16.2 : plans tenus, runs par statut, validations de faits, acceptation des détecteurs, coût par type de run, temps de décision quotidien, interruptions vs budget) sont assemblés en **revue mensuelle automatique** (note dans `99_Assistant/Revues/`) relue avec Bruno : c'est là que se décident les ajustements de seuils, de budgets et de niveaux d'autonomie — sur données, pas sur impressions.
|
||||
|
||||
**P5-H3 — Routage de modèles affiné.** La table de routage par type de job (parent §16.3) est ajustée depuis les métriques : un job dont le taux de rejet/correction dépasse un seuil passe au modèle supérieur (proposition en revue mensuelle, jamais de changement silencieux de coût) ; un job à très haut volume et haute acceptation peut descendre au modèle inférieur, golden tasks du job vertes à l'appui.
|
||||
|
||||
**P5-H4 — Échantillon réel en lecture seule.** Avant chaque livraison de chantier, l'échantillon de scénarios sûrs (lecture seule) tourne contre l'instance réelle de Bruno ; les écarts avec la CI (données réelles vs fixtures) sont consignés dans la fiche du chantier. Aucune écriture réelle n'est jamais produite par ce mécanisme.
|
||||
|
||||
Critères de sortie (le chantier est « en régime » quand) : la revue mensuelle est produite et a été relue au moins deux fois ; le corpus compte les scénarios de toutes les phases livrées ; au moins un ajustement (seuil, budget ou routage) a été décidé sur la base d'une revue.
|
||||
|
||||
## 6. Critères d'acceptation de la phase
|
||||
|
||||
La phase 5 n'a pas de critère global — chaque chantier a les siens. Critères transverses, vérifiés à chaque livraison de chantier :
|
||||
|
||||
1. Le chantier n'a créé **aucun nouveau moteur** ; tout outil nouveau éventuel est justifié dans la fiche du chantier (par défaut : zéro).
|
||||
2. Ses golden tasks sont vertes et entrées au corpus (chantier H).
|
||||
3. Son activation a été précédée de l'affichage complet de ce qu'il ajoute ; sa désactivation ne supprime aucune donnée de Bruno.
|
||||
4. Sa fiche `docs/features/` et son guide sont livrés (DoD commune).
|
||||
|
||||
## 7. Risques spécifiques et vigilance
|
||||
|
||||
| Risque | Vigilance |
|
||||
|---|---|
|
||||
| La phase devient un fourre-tout qui ne finit jamais | C'est assumé : la phase est **optionnelle et étalée** ; le produit est complet sans elle. Le risque serait l'inverse — bloquer le cœur pour un pack |
|
||||
| Des packs médiocres érodent la confiance dans le cœur | La barre de livraison d'un chantier est celle des phases précédentes (golden tasks, DoD) ; un pack qui ne la passe pas ne s'active pas |
|
||||
| Le chantier D (argent) glisse vers du conseil financier | Périmètre lecture/analyse uniquement ; les synthèses citent les données déposées et n'émettent ni recommandation d'achat/vente ni jugement de solvabilité — écrit dans la procédure du pack et dans sa fiche |
|
||||
| La revue mensuelle (#212) devient un rituel ignoré | Elle est courte par construction (une page, chiffres et écarts seulement) et lue dans la revue de semaine existante, pas dans un nouveau rendez-vous |
|
||||
|
||||
## 8. Ordre d'exécution suggéré
|
||||
|
||||
Aucun ordre imposé. Suggestion fondée sur les dépendances et le rapport valeur/effort :
|
||||
|
||||
```
|
||||
Dès la phase 2 : F (apprentissage — aucun connecteur requis)
|
||||
Après la phase 3 : A (veilles) → B (documents & données) → D (argent, après B)
|
||||
Après la phase 4 : C (maison & admin) → E (voyages)
|
||||
Si décision prise : G (sync tâches externe)
|
||||
En continu, dès la phase 0 : H (#212) — ses livrables propres sont en §5
|
||||
```
|
||||
|
||||
## 9. Définition de « terminé »
|
||||
|
||||
Par chantier : DoD du document de phase 0 §12 + critères transverses du §6 ci-dessus. La phase 5 elle-même n'est jamais « terminée » par construction : elle se clos quand Bruno décide qu'aucun chantier restant ne vaut son effort — ce qui est un succès du découpage, pas un échec du plan.
|
||||
@@ -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.
|
||||
+58
-10
@@ -100,13 +100,37 @@ Gestion :
|
||||
|
||||
| Endpoint | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/auth/tokens` | Lister vos clés (`last_used_at`, statut) |
|
||||
| `POST /api/auth/tokens` | Créer (`{name, expiry}`) |
|
||||
| `GET /api/auth/tokens` | Lister vos clés (`last_used_at`, statut, **portée**) |
|
||||
| `POST /api/auth/tokens` | Créer (`{name, expiry, vaults?, scope?}`) |
|
||||
| `DELETE /api/auth/tokens/{jti}` | Révoquer immédiatement (API **et** MCP) |
|
||||
|
||||
> Le JWT brut n'est **jamais persisté** : copiez-le à la création. Plafond :
|
||||
> 50 clés actives par utilisateur.
|
||||
|
||||
### 2.3 Clés à portée limitée (synchronisation externe, ex. TikTak)
|
||||
|
||||
Une clé peut être confinée **à un ou plusieurs vaults** et **aux fichiers seuls** —
|
||||
si elle fuit, le reste reste hors de portée :
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:2020/api/auth/tokens \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"TikTak sync","expiry":"never","vaults":["TikTak"],"scope":"files"}'
|
||||
```
|
||||
|
||||
- `vaults` : sous-ensemble de vos vaults (`400` si inconnu, `403` si hors de vos
|
||||
accès ; omis = tous vos vaults, comme avant).
|
||||
- `scope: "files"` : surface fichiers uniquement (CRUD, listes, recherche,
|
||||
backups lecture/restauration, `GET /api/vaults`, `/mcp`). Tout le reste —
|
||||
admin, config, IA, webhooks, partages, **gestion des clés elle-même** — répond
|
||||
**403**. Les outils MCP hors fichiers sont refusés (`token_scope_denied`) et
|
||||
masqués du `list_tools`.
|
||||
- La portée est affichée dans l'interface (Configurations → **🔑 Clés API & MCP**)
|
||||
et renvoyée par `GET /api/auth/tokens` (`scope`, `vaults`).
|
||||
- Les clés créées avant cette fonction n'ont aucune portée = accès complet,
|
||||
comportement inchangé (non-régression testée).
|
||||
|
||||
---
|
||||
|
||||
## 3. Référence des endpoints
|
||||
@@ -166,7 +190,7 @@ Gestion :
|
||||
| `/api/suggest?q=` | Autocomplétion de titres | GET | Oui |
|
||||
| `/api/tags/suggest?q=` | Autocomplétion de tags | GET | Oui |
|
||||
| `/api/tree-search` | Recherche de fichiers/dossiers | GET | Oui |
|
||||
| `/api/vault/{vault}/paths` | Liste de chemins | GET | Oui |
|
||||
| `/api/vault/{vault}/paths` | Liste de chemins **avec `size`/`mtime`/`mtime_iso`** (réconciliation externe en un appel) | GET | Oui |
|
||||
| `/api/graph/{vault}` | Graphe de liens | GET | Oui |
|
||||
|
||||
### 3.5 Sauvegardes
|
||||
@@ -231,7 +255,7 @@ Gestion :
|
||||
|---|---|---|
|
||||
| `/api/share/{vault}` | Créer un lien de partage public | POST |
|
||||
| `/api/shares` | Lister / supprimer les partages | GET/DELETE |
|
||||
| `/api/webhooks` | CRUD webhooks (HMAC-SHA256) | GET/POST/PATCH/DELETE |
|
||||
| `/api/webhooks` | CRUD webhooks (HMAC-SHA256, **filtres `vault` / `path_prefix`**) | GET/POST/PATCH/DELETE |
|
||||
| `/api/conflicts` · `/api/conflicts/resolve` | Conflits Syncthing | GET/POST |
|
||||
| `/api/plugins` | Installer / activer / désactiver | GET/POST/DELETE |
|
||||
| `/api/push/*` | Abonnement Web Push (VAPID) | GET/POST/DELETE |
|
||||
@@ -285,9 +309,33 @@ curl -s $BASE/api/index/reload -H "Authorization: Bearer $TOKEN"
|
||||
|
||||
---
|
||||
|
||||
## 5. Temps réel
|
||||
## 5. Synchronisation externe (TikTak)
|
||||
|
||||
### 5.1 SSE — `/api/events`
|
||||
Recette complète côté ObsiGate : fiche
|
||||
[`../features/tiktak-integration-197-200.md`](../features/tiktak-integration-197-200.md)
|
||||
(#200 : provisionnement du vault « TikTak », #199 : clé limitée, #198 : webhook
|
||||
filtré). Boucle de réconciliation type avec **un seul appel de liste** :
|
||||
|
||||
```bash
|
||||
KEY=<clé scope=files, vaults=[TikTak]>
|
||||
# 1. Inventaire : chemin + mtime + taille, sans lire les contenus
|
||||
curl -s "$BASE/api/vault/TikTak/paths?limit=20000" -H "Authorization: Bearer $KEY" \
|
||||
| jq -r '.results[] | select(.type=="file") | "\(.mtime) \(.size) \(.path)"' > avant.txt
|
||||
# 2. … modifications …
|
||||
# 3. Détecter les changés par comparaison avec l'inventaire précédent
|
||||
curl -s "$BASE/api/vault/TikTak/paths?limit=20000" -H "Authorization: Bearer $KEY" \
|
||||
| jq -r '.results[] | select(.type=="file") | "\(.mtime) \(.size) \(.path)"' > apres.txt
|
||||
```
|
||||
|
||||
> `PUT /api/file/{vault}/save` rafraîchit l'index de façon synchrone : la re-liste
|
||||
> qui suit voit le nouveau `mtime`/`size` sans attendre le watcher. Les sauvegardes
|
||||
> texte émettent `file_modified` (webhook) pour tirer les retours.
|
||||
|
||||
---
|
||||
|
||||
## 6. Temps réel
|
||||
|
||||
### 6.1 SSE — `/api/events`
|
||||
|
||||
Flux d'événements de changement d'index (fichiers créés/supprimés/modifiés), avec
|
||||
reconnexion automatique côté client.
|
||||
@@ -296,7 +344,7 @@ reconnexion automatique côté client.
|
||||
curl -N "$BASE/api/events"
|
||||
```
|
||||
|
||||
### 5.2 WebSocket — collaboration
|
||||
### 6.2 WebSocket — collaboration
|
||||
|
||||
`ws(s)://<hôte>/ws/collab/{vault}/{path}` transporte les mises à jour
|
||||
Yjs/CRDT et la présence (curseurs distants). Authentification par cookie
|
||||
@@ -305,7 +353,7 @@ Voir [Édition & collaboration](./COLLABORATION.md).
|
||||
|
||||
---
|
||||
|
||||
## 6. Limites et bonnes pratiques
|
||||
## 7. Limites et bonnes pratiques
|
||||
|
||||
- **Rate limiting** : les endpoints de login et les outils IA sont limités ;
|
||||
respectez `retry_after` en cas de `429`.
|
||||
@@ -320,12 +368,12 @@ Voir [Édition & collaboration](./COLLABORATION.md).
|
||||
|
||||
---
|
||||
|
||||
## 7. Dépannage
|
||||
## 8. Dépannage
|
||||
|
||||
| Code | Cause probable |
|
||||
|---|---|
|
||||
| `401` | Jeton absent, expiré ou révoqué |
|
||||
| `403` | Compte sans accès à cette vault / réservé admin |
|
||||
| `403` | Compte sans accès à cette vault / réservé admin / clé à portée limitée hors périmètre |
|
||||
| `404` | Vault, fichier ou chemin inexistant |
|
||||
| `409` | Conflit (fichier déjà existant, etc.) |
|
||||
| `422` | Corps de requête invalide (schéma Pydantic) |
|
||||
|
||||
@@ -19,6 +19,10 @@ Cline, tout client compatible MCP) via un serveur **Streamable HTTP** monté sur
|
||||
(`Authorization: Bearer <token>`). Une seule clé fonctionne pour l'API REST
|
||||
**et** le MCP. Créez-la depuis l'interface (Configurations → 🔑 Clés API & MCP)
|
||||
ou via `POST /api/auth/tokens` — voir [API REST §2.2](./API_REST.md#22-clés-api-longue-durée-recommandé-pour-scripts--mcp).
|
||||
Pour une intégration limitée aux fichiers (ex. TikTak), créez une clé
|
||||
`{"vaults":["TikTak"],"scope":"files"}` ([API REST §2.3](./API_REST.md#23-clés-à-portée-limitée-synchronisation-externe-ex-tiktak)) :
|
||||
les outils hors fichiers sont refusés (`token_scope_denied`) et masqués du
|
||||
`list_tools`, et les autres vaults répondent en erreur d'accès.
|
||||
3. Si l'authentification est désactivée (`OBSIGATE_AUTH_ENABLED=false`), le
|
||||
serveur MCP accepte un utilisateur anonyme disposant de tous les vaults.
|
||||
|
||||
|
||||
+18
-3
@@ -219,8 +219,14 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-109* | Chat : dans la tuile d'une URL (link preview) l'image ne s'affiche jamais | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/file_chat.py` (`build_preview`, `_proxy_image`) | Poster une URL avec `og:image` dans le chat général → la carte s'affiche mais sans vignette (console : `Refused to load the image … Content Security Policy`) | La CSP de réponse (`img-src 'self' data: blob:`) interdit toute image externe, et les `og:image` **relatives** étaient résolues contre l'origine d'ObsiGate (404). La vignette est désormais **téléversée à l'envoi** dans `chat_uploads` et servie same-origin (`/api/chat/attachment/<uuid>`) : résolution `urljoin` contre la page, garde SSRF réutilisée, plafond 2 Mo, allow-list d'extensions ; échec du téléchargement → carte conservée sans vignette (jamais de message perdu) | Tests `test_preview_image_is_proxied_same_origin` + `test_preview_image_failure_keeps_the_card` (contre-preuve : proxy neutralisé → `image == ""`). Cartes antérieures inchangées : leur URL distante était déjà bloquée |
|
||||
| *BUG-111* | Admin : modale « Administration — Utilisateurs » sans icône de fermeture (lucide absent de admin.html) et tableau de bord admin affichant toutes les sections au lieu de la seule section sélectionnée | 2026-10-10 | Utilisateur | `frontend/admin.html` (ajout du script lucide UMD) + `frontend/js/admin.js` (`_wireSectionNav` : show/hide au lieu du scrollspy) ; suites frontend + backend vertes, vérifié sur instance locale 2020 | lucide n'était chargé que dans index.html ; le scrollspy laissait les 4 sections visibles |
|
||||
| *BUG-110* | Chat : refonte visuelle des posts — boutons discrets sous l'image, image ouverte dans un onglet de l'app, point de non-lus sur l'icône chat, notification OS hors focus, annulation **réelle** d'une suppression | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs` | Clic sur une image du chat → ouverture dans un onglet du navigateur au lieu du visualiseur de l'app ; boutons de pièce jointe bavards (icône + libellé) ; aucun repère visuel persistant sur l'icône chat ; aucune alerte OS quand l'app est en arrière-plan ; le bouton « Annuler » d'une suppression ne restaurait rien (copie DOM d'un message déjà supprimé côté serveur) | `TabManager.openChatImage()` (+ branche `chatImage` de `activate()`, `renderImageViewer()` accepte une `url` directe) ouvre la pièce jointe dans un **onglet** avec le visualiseur des images de vault (zoom/pan/plein écran) ; boutons **icônes seules** (24 px, `aria-label`) sous l'image, alignés à droite ; **point rouge pulsant** (`.chat-has-unread`) tant qu'il reste des non-lus, en plus du badge chiffré ; `_notifyNewMessage()` — notification OS si `document.hidden`/hors focus, permission demandée à l'ouverture du chat ; suppression **différée de 10 s** (ligne « Ce message a été supprimé » + « Annuler », DELETE envoyé à l'échéance seulement, échec réseau → le message revient) | Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 |
|
||||
| | | | | | | | | | |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
| *BUG-112* | E2E local : `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine **jamais** — la commande semble bloquée des dizaines de minutes alors que le serveur est prêt en ~7 s | 🟢 corrigé | P1 | 🧪 E2E local / Windows | IA | `scripts/e2e-server.ps1` (`start`) | `pwsh -NoProfile -File scripts/e2e-server.ps1 start 2>&1 \| head -n 20` → pipeline ouvert ~500 s+ ; se termine en <2 s dès qu'uvicorn est tué (problème d'EOF du pipe, pas de lenteur du démarrage) | `Start-Process` laissait uvicorn hériter des handles stdout/stderr du pwsh parent → le pipe `\| head` reste ouvert tant que le serveur vit → `head` attend un EOF qui n'arrive jamais. Remplacé par `Win32_Process.Create` (WMI) : processus détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs) | Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers indexés), `stop`/`status` OK, syntaxe PS validée, aucun orphelin |
|
||||
| *BUG-113* | Perf : suppression d'un répertoire dans la vault home-user très lente — le temps croît avec la vault entière, pas avec le dossier supprimé | 🟢 corrigé | P1 | ⚙️ backend / perf | IA | `backend/routers/files_write.py` (`api_directory_delete`), `backend/indexer.py` (`remove_directory_from_index` nouveau) | Supprimer un gros répertoire du home-user → l'API met des secondes (re-scan complet de la vault + rebuild de l'index inversé global) | `DELETE /api/directory/{vault}` appelait `reload_single_vault` après le `rmtree` : re-scan de TOUTE la vault + `init_inverted_index()` global. Remplacé par `remove_directory_from_index` : retire du sous-arbre uniquement (fichiers + entrées dossiers de `path_index`, notification incrémentale de l'index inversé et sémantique via le hook existant) | Mesuré sur l'instance E2E : 60 fichiers supprimés → **63 ms** vs **668 ms** avant (~10×) ; aucun fantôme dans `/api/vault/{v}/paths` ni dans la recherche plein-texte après suppression (`test_delete_directory_subtree_clears_index`) ; 232 tests verts, ruff 0, mypy 0 |
|
||||
| *BUG-114* | Popup « currentPath is not defined » après suppression (ou renommage) d'un répertoire | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ui.js` (`confirmDeleteDirectory`, renommage inline) | Ouvrir un fichier d'un dossier, clic droit → Supprimer le dossier (ou Renommer) → toast d'erreur `currentPath is not defined` alors que l'opération réussit | Les branches répertoire testaient `currentPath` nu (non déclaré dans ui.js — la variable vit dans `state`) au lieu de `state.currentPath` : `ReferenceError` dans le `try`, avalée par le `catch` → toast. Trois usages corrigés (startsWith ×2, slice ×1) | Vérifié en Playwright (instance 2029, flux réel clic droit → Supprimer sur un dossier contenant le fichier ouvert) : toast « Dossier supprimé (1 fichiers) », 0 fantôme dans /api/vault/{v}/paths, 0 erreur JS ; validate-imports 42 modules, unit.test.mjs 13/13 |
|
||||
| *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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
@@ -345,7 +351,16 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
|
||||
| 2026-10-09 | #194 | Fonctionnalité (UI) | `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `docker-compose.test-win.yml`, `.gitignore`, `tests/frontend/sidebar-filters.test.mjs`, `docs/ROADMAP.md`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 (incrément UI)** : les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de la sidebar — tri stable appliqué à la source (`_homeFirst` dans `loadVaults()` et dans le soft-refresh, donc `state.allVaults`, l'arborescence et les sélecteurs restent cohérents) — et portent l'icône **maison** lucide (`data-lucide="home"` via `getVaultIcon`, même `safeCreateIcons`/`stroke: currentColor` que les autres icônes, thème-aware). Compose de test : `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (dossier `test_home/` ignoré par git). Vérifié : `sidebar-filters.test.mjs` 8→**10/10**, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13, suites voisines (ai-sidebar, toolbar-order, search-facets, pane-manager, editor-inline) vertes, **live Playwright sur l'instance 2020** : ordre `home-admin, home-bruno, TestDir, TestVault` + `svg.lucide-home` rendu (placeholder `<i>` remplacé) | ✅ 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-14 | BUG-112 | Correction | `scripts/e2e-server.ps1`, `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **BUG-112 — `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine jamais.** Constat : le démarrage n'est pas lent (~7 s, READY, health 200, 137 fichiers indexés) — c'est le pipe qui ne reçoit jamais d'EOF. Preuve : le pipeline survit 494 s+ puis se termine en <2 s dès qu'uvicorn est tué. Cause : `Start-Process` laisse l'enfant hériter des handles stdout/stderr du pwsh parent ; le serveur (immortel par design) maintient le pipe ouvert → `head` attend un EOF qui n'arrivera jamais. Correctif : `Win32_Process.Create` (WMI) lance uvicorn détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs). Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers), `stop`/`status` OK, syntaxe PS validée, aucun orphelin | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-14 | BUG-113 | Perf | `backend/indexer.py`, `backend/routers/files_write.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-113 — suppression de répertoire ~10× plus rapide.** Constat : dans le home-user, supprimer un répertoire prenait des secondes — le temps croissait avec la **vault entière**, pas avec le dossier supprimé. Cause : `DELETE /api/directory/{vault}` appelait `reload_single_vault` après le `rmtree` : re-scan complet de la vault + rebuild de l'index inversé **global** (toutes les vaults). Correctif : `remove_directory_from_index` (indexer.py) retire uniquement les fichiers du sous-arbre (files, tags, `_file_lookup`, `path_index` avec purge des dossiers fantômes) et notifie l'index inversé + sémantique **incrémentalement** via le hook existant — le même chemin que le watcher pour une suppression de fichier. Mesuré sur l'instance E2E : 60 fichiers supprimés → **63 ms** vs **668 ms** avant. Tests : `test_delete_directory_subtree_clears_index` (aucun fantôme dans `/api/vault/{v}/paths` ni dans la recherche plein-texte après suppression). Vérifié : 232 passed / 1 skip (api_main + indexer_advanced + tools_mutations + search), ruff 0, mypy 0 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+316
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.66.0 | **Dernière mise à jour :** 2026-10-10
|
||||
> **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,225 @@
|
||||
|
||||
## 🔵 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)
|
||||
- **Fiche :** [features/fusion-bloc1-barre-refs-215.md](./features/fusion-bloc1-barre-refs-215.md)
|
||||
- **Description :** première tranche du programme **Fusion** (accès centralisé
|
||||
ObsiGate / Flowdeck / TikTak / Kanbante — dépôt programme
|
||||
`Projets/fusion-trois-apps`, épopée ACC-1 ; contrats versionnés dans
|
||||
`Projets/dracodev-commons` v0.1.0). ID 215 choisi hors des plages déjà
|
||||
utilisées ou proposées (#191–#201 livrées/Assistant V2, #202–#214
|
||||
proposées par ASSISTANT-V2.1). Issues Gitea ObsiGate #5 (ACC-1.1),
|
||||
#6 (ACC-1.2), #7 (ACC-1.3).
|
||||
- **Sous-tâches :**
|
||||
- [x] Barre d'apps commune vendangée du snippet figé de commons
|
||||
(`frontend/draco-barre.css/.js`, markup dans `index.html`) :
|
||||
ObsiGate marquée active, navigation vers les 4 apps, domaine mémorisé
|
||||
- [x] Backend `external_ref` : stockage JSON par utilisateur
|
||||
(`backend/external_refs.py`), endpoints `GET/POST /api/refs` et
|
||||
`DELETE /api/refs/{id}` (schémas + tag OpenAPI), parseur d'URL
|
||||
profondes conforme au contrat figé
|
||||
- [x] Section « Liens » dans la vue d'une note (module
|
||||
`frontend/js/draco-liens.js`, point d'extension
|
||||
`obsigate:file-rendered` émis par `renderFile`)
|
||||
- [x] Notes projetées TikTak reconnues (frontmatter `tiktak_id`) :
|
||||
bandeau « Tâche TikTak projetée » + « Ouvrir dans TikTak »
|
||||
- [x] i18n FR/EN (clés `draco.links.*`), tests `test_external_refs.py`
|
||||
|
||||
### 216. Fusion ACC-3.1 — émettrice SSO `draco_session`
|
||||
|
||||
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.71.0)
|
||||
- **Fiche :** [features/fusion-acc31-sso-draco-session-216.md](./features/fusion-acc31-sso-draco-session-216.md)
|
||||
- **Description :** couche 2 (identité) du programme **Fusion** — décision C2
|
||||
de Bruno : cookie de session partagé sous `.dracodev.net` (architecture
|
||||
v2.0 §5.2, option 2a). ObsiGate est l'unique émettrice ; les autres apps
|
||||
valideront localement (ACC-3.2). Issue Gitea ObsiGate #8 ; épopée
|
||||
Projets/fusion-trois-apps#7.
|
||||
- **Sous-tâches :**
|
||||
- [x] Module `backend/draco_session.py` : JWS HS256, claims minimaux
|
||||
(sub, name, iat, exp 12 h, jti — aucun rôle), rotation par
|
||||
`DRACO_SESSION_SECRET_PREV`, inerte sans `DRACO_SESSION_SECRET`
|
||||
- [x] Émission du cookie à chaque `_issue_tokens` (mot de passe ou MFA)
|
||||
- [x] Renouvellement glissant (middleware ASGI `DracoRenewalMiddleware`,
|
||||
fenêtre 2 h, conditionné au cookie d'accès natif valide)
|
||||
- [x] Déconnexion globale : révocation du jti (`data/draco_revoked.json`)
|
||||
+ effacement du cookie ; `GET /api/sso/revocations` sous clé machine
|
||||
- [x] `GET /sso/authorize` : `return_to` en liste blanche
|
||||
`*.dracodev.net` (anti open-redirect), retour post-connexion
|
||||
re-validé côté frontend (`auth.js`)
|
||||
- [x] Tag OpenAPI « SSO », tests `test_draco_session.py` (22)
|
||||
|
||||
### 217. Fusion ACC-3.2 — validation locale du jeton `draco_session`
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.72.0)
|
||||
- **Fiche :** [features/fusion-acc32-validation-locale-217.md](./features/fusion-acc32-validation-locale-217.md)
|
||||
- **Description :** pendant consommateur de #216 — `get_current_user`
|
||||
accepte le cookie partagé à défaut de jeton natif valide ; le `sub`
|
||||
étant l'identifiant ObsiGate, la correspondance vers le compte local
|
||||
est directe (compte actif exigé, garde-fou BUG-028 appliqué).
|
||||
Issue Gitea ObsiGate #9 ; épopée Projets/fusion-trois-apps#7.
|
||||
- **Sous-tâches :**
|
||||
- [x] `draco_session.user_for_draco()` : résolution par id, `_draco_sub`
|
||||
- [x] Repli `_draco_fallback()` sur toutes les sorties « non
|
||||
authentifié » du chemin natif (`backend/auth/middleware.py`)
|
||||
- [x] Tests `test_draco_session.py` (+4 : cookie seul, révoqué,
|
||||
sub inconnu, inerte sans secret)
|
||||
|
||||
### 196. Partage dirigé entre utilisateurs (document → utilisateur)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
|
||||
@@ -48,6 +267,30 @@
|
||||
- [x] Scope de `GET /api/shares` ; révocation créateur/admin uniquement
|
||||
- [x] UI : dialogue dirigé/public + destinataires, dashboard « partagé par X »
|
||||
- [x] i18n FR/EN, tests `test_directed_shares.py` (9), fiche feature
|
||||
- [x] **Itération 2 (2026-10-11)** : icône « partagé » (share-2 bleu) sur les
|
||||
fichiers porteurs d'un partage actif dans l'arborescence ; dossier
|
||||
virtuel **Partage** à la racine du home du destinataire (fichiers reçus,
|
||||
ouverture lecture seule `/s/{token}`) ; les documents reçus sont
|
||||
**cherchables** (merge dans `search_vaults`, résultats
|
||||
`home-<user>/Partage/…` avec `share_token`, clic → page de partage —
|
||||
jamais l'éditeur du vault d'autrui) ; cache front invalidé à la
|
||||
création/révocation. +2 tests (recherche trouvée par le destinataire,
|
||||
aucune fuite vers un non-destinataire).
|
||||
- [x] **Itération 3 (2026-10-11)** : le dossier Partage ne déclenche plus la
|
||||
navigation (dossier virtuel — corrige « Directory not found: Partage ») ;
|
||||
un fichier reçu s'ouvre **dans l'application en onglet** (arbre,
|
||||
recherche, dashboard) via la résolution serveur
|
||||
`home-<user>/Partage/<fichier>` → fichier source dans `/api/file`
|
||||
(+ raw, download) ; validation `path` non-nulle à la création d'un
|
||||
partage. +2 tests.
|
||||
- [x] **Itération 4 (2026-10-11)** : le partage dirigé EST l'autorisation —
|
||||
la résolution `Partage/…` court-circuite l'ACL du vault source (fix
|
||||
« Accès refusé à la vault … » : le destinataire n'a par définition
|
||||
pas accès au vault de l'émetteur) ; chemin virtuel canonique
|
||||
`Partage/<token>/<nom>` (lève l'ambiguïté de fichiers homonymes,
|
||||
fallback par nom conservé) ; « reçu » = adressé à moi (un admin ne
|
||||
voit plus les partages d'autres comptes comme reçus — fix 404
|
||||
« File not found: Partage/… »). +3 tests.
|
||||
|
||||
### 194. Dossier personnel par utilisateur (`<racine>/Home/<user>`)
|
||||
|
||||
@@ -76,6 +319,75 @@
|
||||
|
||||
---
|
||||
|
||||
### 197. Listes de fichiers : `mtime` + `size` pour réconciliation externe (TikTak, Gitea #4)
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-14 | **Statut :** ✅ livré le 2026-10-14
|
||||
- **Fiche :** [features/tiktak-integration-197-200.md](./features/tiktak-integration-197-200.md)
|
||||
- **Description :** un client de synchro doit détecter les fichiers modifiés en **un
|
||||
seul appel de liste**, sans relire chaque fichier. `GET /api/vault/{vault}/paths`
|
||||
n'exposait que `vault/path/name/type`.
|
||||
- **Sous-tâches :**
|
||||
- [x] `/paths` : `size`/`mtime`/`mtime_iso` par fichier (index, même contrat que
|
||||
`list_all_files`) ; `None` sur les répertoires
|
||||
- [x] `/browse` : `modified`/`modified_iso` ; outil MCP `list_directory` : `size`/`mtime`
|
||||
- [x] Cohérence read-after-write : `PUT …/save` rafraîchit l'index en synchrone
|
||||
- [x] Bug latent corrigé : `update_single_file`/`remove_single_file` exigeaient un
|
||||
absolu mais recevaient un relatif (no-op silencieux) — acceptent les deux
|
||||
- [x] Tests `tests/test_sync_lists.py` (7 : métadonnées + scénario de réconciliation)
|
||||
|
||||
### 198. Webhooks : `vault` dans la charge + filtre `vault` / `path_prefix` (TikTak, Gitea #3)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-14 | **Statut :** ✅ livré le 2026-10-14
|
||||
- **Fiche :** [features/tiktak-integration-197-200.md](./features/tiktak-integration-197-200.md)
|
||||
- **Description :** un consommateur externe (retours de synchro TikTak, phase 3) recevait
|
||||
tout, de tous les vaults, sans pouvoir filtrer.
|
||||
- **Sous-tâches :**
|
||||
- [x] Filtres optionnels `vault` (exact) + `path_prefix` (sous-arbre, `old/new_path`
|
||||
inclus) ; sans filtre = tout, comme avant ; `webhook_matches()` pur et testé
|
||||
- [x] `POST`/`PATCH`/`GET /api/webhooks` : filtres acceptés, modifiables (`""` = reset), exposés
|
||||
- [x] `PUT …/save` émet enfin `file_modified` (souscrit mais jamais dispatché)
|
||||
- [x] UI : champs filtre + badge, aide intégrée FR/EN mise à jour
|
||||
- [x] Tests `tests/test_webhook_filters.py` (10 : matching, persistance, dispatch simulé)
|
||||
|
||||
### 199. Clés API à portée limitée — vault unique + fichiers seuls (TikTak, Gitea #2)
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡 | **Ouvert :** 2026-10-14 | **Statut :** ✅ livré le 2026-10-14
|
||||
- **Fiche :** [features/tiktak-integration-197-200.md](./features/tiktak-integration-197-200.md)
|
||||
- **Description :** la clé que TikTak conservera ne doit toucher que le vault « TikTak »
|
||||
et que les fichiers — en cas de fuite, le reste de la mémoire reste hors de portée.
|
||||
- **Sous-tâches :**
|
||||
- [x] `POST /api/auth/tokens` : `vaults?` (sous-ensemble du créateur, 400/403 sinon) +
|
||||
`scope` (`full` défaut | `files`) ; JWT + record portent la portée
|
||||
- [x] Vault : `check_vault_access`/`require_vault_access` existants → 403 hors vault (REST + MCP)
|
||||
- [x] Périmètre `files` : `TokenScopeMiddleware` (403 REST, stateless) + `call_tool`/
|
||||
`propose_*`/`list_tools`/`vault://` (`token_scope_denied`, `FILES_SCOPE_TOOLS`)
|
||||
- [x] UI : sélecteur de portée + champ vaults, badge et vaults listés (i18n FR/EN)
|
||||
- [x] Compatibilité : clés existantes = `full`, inchangées
|
||||
- [x] Tests `tests/test_scoped_tokens.py` (13) + guides API_REST (§2.3, §5) / MCP
|
||||
|
||||
### 200. Provisionnement du vault dédié « TikTak » (exploitation, Gitea #1)
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-14 | **Statut :** ✅ livré le 2026-10-14 (runbook)
|
||||
- **Fiche :** [features/tiktak-integration-197-200.md](./features/tiktak-integration-197-200.md) (§#200)
|
||||
- **Description :** opération unique côté hébergement, aucun code requis : dossier hôte +
|
||||
montage conteneur + `POST /api/vaults/add` (persisté, sans redémarrage) + ACL du
|
||||
compte de synchro + clé limitée (#199) + webhook filtré (#198) + recette `curl`
|
||||
d'acceptation. **Reste à exécuter sur l'hôte** (suivre le runbook, puis fermer Gitea #1).
|
||||
|
||||
### 201. Menu contextuel « Déplacer » / « Dupliquer » + réparation du drag & drop interne (sidebar, BUG-117)
|
||||
|
||||
- **Effort :** 0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-15 | **Statut :** ✅ livré le 2026-10-15
|
||||
- **Description :** déplacement/duplication fichiers et répertoires via le menu contextuel
|
||||
(modal `promptTargetFolder` réutilisé et harmonisé, emplacement actuel pré-sélectionné,
|
||||
cross-vault refusé proprement) ; endpoint `POST /api/duplicate/{vault}` (`duplicate_path` :
|
||||
copie `X copie.ext`, collisions numérotées, refus de dupliquer un dossier dans lui-même,
|
||||
index incrémental, SSE + webhooks) ; drag & drop interne réparé (le drop était avalé en
|
||||
silence par la détection `isExternalFiles` qui comptait les formats string `tree:*` du
|
||||
glisser interne) ; i18n FR/EN ; 4 tests `TestDuplicateApi` + validation Playwright E2E.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 77. Application Desktop native — Tauri (Windows / Linux / macOS)
|
||||
|
||||
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Framework :** Tauri v2 (Rust + Webview)
|
||||
@@ -854,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.
|
||||
@@ -77,3 +77,67 @@ peut pas révoquer (403), créateur révoque, la révocation coupe l'accès.
|
||||
rechargement du dashboard (badge/rafraîchissement suffisent ; SSE existe
|
||||
si un vrai push est demandé).
|
||||
- La date d'« expiration » reste globale au partage (pas par destinataire).
|
||||
|
||||
## Itération 2 — visibilité & recherche (2026-10-11)
|
||||
|
||||
| Élément | Où |
|
||||
|---|---|
|
||||
| Icône « partagé » (share-2 bleu) sur les fichiers de l'arborescence | `frontend/js/sidebar.js` (`loadSharedPaths` + `sharedFileIconSync`, appliqué dans `loadDirectory` et `incrementalLoadDirectory`) |
|
||||
| Dossier virtuel **Partage** à la racine du home du destinataire | `frontend/js/sidebar.js` (injection `virtual-share` + rendu, clic fichier → `/s/{token}`) |
|
||||
| Recherche des documents reçus | `backend/services/search.py::_shared_results` (merge dans `search_vaults` avant pagination), `backend/routers/search.py` (passe `username`), `SearchResultItem.share_token` |
|
||||
| Clic sur un résultat reçu → page de partage | `frontend/js/search.js` |
|
||||
| Invalidation du cache front | `invalidateSharedPaths()` exporté, appelé par `openShareDialog` après création/révocation |
|
||||
|
||||
Décisions :
|
||||
|
||||
- **Dossier virtuel, pas physique** : l'indexeur exclut les symlinks
|
||||
(sécurité path traversal) et un dossier réel ferait double indexation.
|
||||
La source de vérité reste `data/shares.json`.
|
||||
- **Recherche à la volée** (`_shared_results`) : le contenu des documents
|
||||
reçus est lu au moment de la requête, uniquement pour les destinataires —
|
||||
jamais indexé dans le TF-IDF global, donc aucune fuite vers un
|
||||
non-destinataire (testé). ponytail: lecture disque par requête, plafond
|
||||
~quelques centaines de partages reçus par user ; indexer dans le TF-IDF
|
||||
avec un champ "destinataires" si le volume devient réel.
|
||||
- Icône déterminée côté client via `/api/shares` (déjà scopé #196) : la
|
||||
liste des partages actifs "créés OU reçus" suffit — le créateur voit
|
||||
l'icône sur son fichier source, le destinataire voit le dossier Partage.
|
||||
|
||||
+2 tests : `test_search_finds_received_share_for_recipient`,
|
||||
`test_search_does_not_leak_share_to_other_user`.
|
||||
|
||||
|
||||
## Itération 3 — ouverture en onglet (2026-10-11)
|
||||
|
||||
- **Résolution serveur** : `_resolve_shared_file()` (`backend/routers/files_read.py`)
|
||||
mappe `home-<user>/Partage/<fichier>` vers le fichier source pour les routes
|
||||
`/api/file`, `/api/file/raw`, `/api/file/download`. Résolu AVANT l'ACL vault
|
||||
(le home virtuel peut ne pas être dans `user.vaults`) — l'autorisation réelle
|
||||
est l'appartenance au partage, vérifiée dans le resolver (destinataire seul).
|
||||
- **Front** : clic dossier Partage → simple dépliage (plus de `openNav`, fix
|
||||
« Directory not found ») ; clic fichier reçu → `TabManager.openPreview/openPersistent`
|
||||
(arbre, recherche, dashboard) — même parcours qu'un fichier ordinaire.
|
||||
- `POST /api/share` : `path` vide/null → 400 (un share `path: None` cassait
|
||||
`/api/shares` en 500).
|
||||
+2 tests : ouverture applicative du fichier reçu (file + raw), résolution
|
||||
scopée par utilisateur (home d'autrui → 403).
|
||||
|
||||
|
||||
## Itération 4 — correctifs d'ouverture (2026-10-11)
|
||||
|
||||
- **Le partage EST l'autorisation** : après résolution `Partage/…`, l'ACL du
|
||||
vault source n'est plus appliquée (`elif` dans `/api/file`, raw, download) —
|
||||
un destinataire sans accès au vault émetteur ouvrait « Accès refusé à la
|
||||
vault '…' », précisément le cas d'usage du partage. L'appartenance au
|
||||
partage (vérifiée dans `_resolve_shared_file` via `list_shares(user=…)`)
|
||||
reste la seule autorisation.
|
||||
- **Chemin canonique `Partage/<token>/<nom>`** : lève l'ambiguïté de deux
|
||||
partages homonymes (le token identifie le partage exact). Fallback par nom
|
||||
conservé pour les liens historiques. Transporté par l'arbre (`data-path`),
|
||||
la recherche et le dashboard.
|
||||
- **« Reçu » = adressé à moi** : sidebar `_loadReceivedShares` et dashboard
|
||||
filtrent désormais `shared_with.includes(me)` — un admin voyant TOUS les
|
||||
partages (`/api/shares` non scopé pour lui) n'a plus les partages d'autres
|
||||
comptes dans son dossier Partage (source des « File not found »).
|
||||
+3 tests : destinataire sans accès au vault source (200), disambiguïsation
|
||||
par token, home d'autrui (403). 14 tests au total.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# #216 — Émettrice SSO `draco_session` (Fusion ACC-3.1)
|
||||
|
||||
Statut : ✅ livré le 2026-10-21 (2.71.0)
|
||||
|
||||
## Contexte
|
||||
|
||||
Programme Fusion (accès centralisé ObsiGate / Flowdeck / TikTak /
|
||||
Kanbante), couche 2 — identité. Décision **C2** de Bruno (2026-10-20) :
|
||||
cookie de session partagé `draco_session` sous `.dracodev.net`
|
||||
(architecture v2.0 §5.2, option 2a). ObsiGate est l'**unique
|
||||
émettrice** ; les autres applications valideront le jeton localement
|
||||
(ACC-3.2, leurs tickets respectifs) et redirigeront leur connexion
|
||||
vers `GET /sso/authorize`.
|
||||
|
||||
## Ce qui est livré
|
||||
|
||||
- **`backend/draco_session.py`** — émission et vérification du JWS
|
||||
(HS256, jose) : claims minimaux `sub` (id utilisateur ObsiGate),
|
||||
`name`, `iat`, `exp` (**12 h**), `jti` ; **aucun rôle** dans le
|
||||
jeton. Rotation : `DRACO_SESSION_SECRET_PREV` accepté en
|
||||
vérification pendant la fenêtre de bascule.
|
||||
- **Émission à la connexion** : `_issue_tokens` (auth/router.py) pose
|
||||
le cookie à chaque émission de jetons natifs (mot de passe ou MFA) —
|
||||
attributs `Domain=.dracodev.net; Path=/; HttpOnly; Secure (si la
|
||||
requête est en https, via `is_secure_cookies`); SameSite=Lax`.
|
||||
- **Renouvellement glissant** : `DracoRenewalMiddleware` (ASGI pur,
|
||||
aucun tamponnage des flux SSE/WS) — si la requête porte un cookie
|
||||
d'accès natif valide et que le cookie draco est absent, invalide ou
|
||||
à moins de 2 h de son expiration, un cookie frais est ajouté à la
|
||||
réponse.
|
||||
- **Déconnexion globale** : le logout révoque le `jti` draco
|
||||
(en plus des jetons natifs) et efface le cookie du domaine parent.
|
||||
Révocations persistées dans `data/draco_revoked.json`
|
||||
(jti → exp, purge des expirés), exposées aux autres apps par
|
||||
**`GET /api/sso/revocations`** protégé par la clé machine
|
||||
`DRACO_SSO_MACHINE_KEY` (en-tête `X-Draco-Machine-Key`, 404 si la
|
||||
clé n'est pas configurée) — cache 60 s côté consommatrices.
|
||||
- **`GET /sso/authorize?return_to=`** : valide le `return_to`
|
||||
(https + `dracodev.net` ou sous-domaine, liste blanche stricte
|
||||
anti open-redirect) puis redirige vers la connexion ObsiGate ;
|
||||
après connexion, `frontend/js/auth.js` repart vers l'application
|
||||
demanderesse (re-validation du domaine côté client).
|
||||
- **Inerte par défaut** : sans `DRACO_SESSION_SECRET`, aucun cookie
|
||||
n'est émis et le comportement d'ObsiGate est inchangé.
|
||||
|
||||
## Configuration (déploiement)
|
||||
|
||||
| Variable | Rôle |
|
||||
|---|---|
|
||||
| `DRACO_SESSION_SECRET` | Secret partagé de signature — **le même** devra être configuré dans Flowdeck et TikTak pour leurs validateurs ACC-3.2. À générer fort (ex. 64 hex) et à ne jamais committer. |
|
||||
| `DRACO_SESSION_SECRET_PREV` | Ancien secret pendant une rotation (24 h). |
|
||||
| `DRACO_SESSION_COOKIE_DOMAIN` | Domaine du cookie (défaut `.dracodev.net` ; vide = hôte seul, dev local). |
|
||||
| `DRACO_SSO_MACHINE_KEY` | Clé machine de lecture des révocations, à partager avec les apps consommatrices. |
|
||||
|
||||
⚠️ **Déploiement** : la fonction ne s'active qu'au redéploiement
|
||||
d'ObsiGate ≥ 2.71.0 **avec** `DRACO_SESSION_SECRET` posé dans
|
||||
l'environnement du conteneur. Le SSO complet exige ensuite les
|
||||
validateurs ACC-3.2 (ObsiGate #9, Flowdeck #21, TikTak #213).
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_draco_session.py` — 22 tests : claims minimaux, durée de
|
||||
vie, falsification, expiration, rotation, révocation, fenêtre de
|
||||
renouvellement, liste blanche `return_to` (8 cas), cookie posé à la
|
||||
connexion (attributs), inactivité sans secret, révocation au logout,
|
||||
renouvellement glissant via middleware, endpoint de révocations
|
||||
(404/401/200).
|
||||
@@ -0,0 +1,51 @@
|
||||
# #217 — Validation locale du jeton draco_session (Fusion ACC-3.2)
|
||||
|
||||
Statut : ✅ livré le 2026-10-21 (2.72.0)
|
||||
|
||||
## Contexte
|
||||
|
||||
Suite de l'émettrice SSO (#216, ACC-3.1). Ici, ObsiGate joue son rôle
|
||||
de **consommatrice** : `get_current_user` accepte le cookie partagé
|
||||
`draco_session` à défaut de jeton natif valide. C'est la brique qui
|
||||
servira aussi à l'API de l'agent commun (ACC-4) pour authentifier les
|
||||
requêtes SSO. Les validateurs des autres applications sont leurs
|
||||
tickets respectifs (Flowdeck #21, TikTak #213 — TikTak 0.52.0).
|
||||
|
||||
## Correspondance sub → compte local
|
||||
|
||||
ObsiGate est émettrice ET consommatrice : le `sub` du jeton **est**
|
||||
son identifiant d'utilisateur — la résolution est directe, par `id`,
|
||||
dans `draco_session.user_for_draco()` :
|
||||
|
||||
- signature/expiration/révocation vérifiées par `verify_token` (#216) ;
|
||||
- compte **actif** exigé ;
|
||||
- garde-fou BUG-028 appliqué à l'identique : un jeton draco émis
|
||||
avant le dernier changement de mot de passe (`password_changed_at`)
|
||||
est refusé ;
|
||||
- le dict utilisateur retourné porte `_token_vaults` (droits du
|
||||
compte), `_token_scope = "full"` et `_draco_sub` (traçabilité).
|
||||
|
||||
## Branchement
|
||||
|
||||
Dans `backend/auth/middleware.py`, chaque sortie « non authentifié »
|
||||
du chemin natif (aucun jeton, jeton illisible, jti natif révoqué,
|
||||
compte introuvable/inactif, jeton antérieur au changement de mot de
|
||||
passe) passe par `_draco_fallback()`. Sûreté du repli après
|
||||
déconnexion globale : le logout révoque aussi le `jti` draco et
|
||||
efface le cookie (#216), donc un ancien cookie rejoué est refusé par
|
||||
`verify_token`.
|
||||
|
||||
## Limite connue
|
||||
|
||||
Le repli authentifie les **appels d'API**. Le bootstrap d'une
|
||||
session complète du SPA (jetons natifs émis à partir du seul cookie
|
||||
draco) n'est pas couvert : pour ObsiGate, émettrice, la connexion
|
||||
native EST la connexion SSO — le cas d'usage du repli est la
|
||||
continuité d'API quand le jeton natif a expiré avant le cookie
|
||||
partagé, et les consommateurs machine de l'API.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_draco_session.py` (+4) : `/api/auth/me` avec le seul
|
||||
cookie draco (200, bon utilisateur), jti révoqué refusé, sub inconnu
|
||||
refusé, inactivité sans secret configuré.
|
||||
@@ -0,0 +1,73 @@
|
||||
# #215 — Fusion bloc 1 : barre d'apps commune et références croisées des notes
|
||||
|
||||
> Programme **Fusion** (accès centralisé entre ObsiGate, Flowdeck, TikTak et
|
||||
> Kanbante). Dépôt programme : `Projets/fusion-trois-apps` (épopée ACC-1,
|
||||
> issues ObsiGate #5, #6, #7). Contrats : `Projets/dracodev-commons` v0.1.0
|
||||
> (`contrats/liens-profonds.md`, `contrats/external_ref.schema.json`,
|
||||
> snippet `ui/barre-apps/`).
|
||||
|
||||
## Ce qui est livré
|
||||
|
||||
### ACC-1.1 — Barre d'apps commune (#5)
|
||||
|
||||
Le snippet figé de commons est **vendangé** (copié, jamais chargé depuis une
|
||||
autre app — la barre survit à l'arrêt des autres applications) :
|
||||
|
||||
- `frontend/draco-barre.css` / `frontend/draco-barre.js` : copie v0.1.0 ;
|
||||
- markup + configuration dans `frontend/index.html`, au-dessus de `#app`
|
||||
(`body.has-draco-bar` ajuste la hauteur de `.app-container` dans
|
||||
`style.css`).
|
||||
|
||||
Phase 1 : navigation entre les quatre applications (ObsiGate marquée
|
||||
active), sélecteur de domaine mémorisé en `localStorage` (`draco_domain`,
|
||||
même clé que TikTak), recherche = focus de la recherche native, rail =
|
||||
bouton latéral natif, menu = menu d'en-tête natif. Alertes unifiées et
|
||||
panneau Spark affichent un message d'arrivée (fédération et agent commun :
|
||||
phases ultérieures du programme) plutôt que des boutons morts. Les compteurs
|
||||
croisés viendront avec la fédération ; seul le compteur local de chaque app
|
||||
est alimenté en phase 1.
|
||||
|
||||
### ACC-1.3 — Références croisées `external_ref` (#7)
|
||||
|
||||
- **Modèle** : le schéma figé de commons. La note est propriétaire de ses
|
||||
références (`owner_type = obsigate.note`, `owner_id = "<vault>:<chemin>"`).
|
||||
- **Stockage** : JSON par utilisateur dans `data/external_refs/`
|
||||
(`backend/external_refs.py`, même patron que `backend/history.py` —
|
||||
pas de base de données). Surchargable par `OBSIGATE_EXTERNAL_REFS_DIR`.
|
||||
- **API** (auth + `check_vault_access`) :
|
||||
- `GET /api/refs?vault=&path=` — références d'une note ;
|
||||
- `POST /api/refs` `{vault, path, url, label?}` — création par collage
|
||||
d'URL profonde ; le parseur reconnaît les formes du contrat (TikTak
|
||||
`?task=`/`?list=`, ObsiGate `#file=`, Flowdeck `/pages/`/`/collections/`,
|
||||
issue Gitea = carte Kanbante) et répond **422** explicite sinon ;
|
||||
- `DELETE /api/refs/{id}`.
|
||||
- **UI** : section « Liens » en fin de note — liste avec pastille de couleur
|
||||
de l'app cible, formulaire d'ajout, retrait en un clic. Rendue par le
|
||||
module autonome `frontend/draco-liens.js`, branché sur l'événement
|
||||
`obsigate:file-rendered` que `renderFile` émet (point d'extension
|
||||
documenté dans `frontend/app.js`).
|
||||
|
||||
### ACC-1.2 — Notes projetées TikTak (#6)
|
||||
|
||||
La synchro TikTak ↔ ObsiGate écrit `tiktak_id: <id>` dans le frontmatter des
|
||||
fichiers projetés. Le module `draco-liens.js` le détecte (lecture du brut via
|
||||
`/api/file/{vault}/raw`) et affiche un bandeau « Tâche TikTak projetée » avec
|
||||
le bouton **« Ouvrir dans TikTak »** (`https://tiktak.dracodev.net/?task=<id>`),
|
||||
au lieu de traiter la note comme une note ordinaire.
|
||||
|
||||
## Limites assumées (v1)
|
||||
|
||||
- Le libellé d'une référence est un instantané (titre par défaut dérivé de
|
||||
l'URL ou libellé libre) ; le rafraîchissement automatique du titre viendra
|
||||
avec la fédération MCP (phase 3 du programme).
|
||||
- Un lien dont la cible a disparu n'est pas détecté automatiquement
|
||||
(`status` reste `ok`) — la marque « cible introuvable » viendra avec la
|
||||
couche données.
|
||||
- La section « Liens » n'apparaît que dans la vue lecture d'une note
|
||||
(pas dans l'éditeur).
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_external_refs.py` : parseur (6 formes reconnues, 7 refusées),
|
||||
stockage (cycle + isolation par utilisateur), API (cycle complet, 422, 404,
|
||||
403 vault inconnu).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,132 @@
|
||||
# Intégration TikTak ↔ ObsiGate — socle côté ObsiGate (#197 → #200)
|
||||
|
||||
> **Statut :** ✅ livré — **IDs stables :** #197, #198, #199, #200
|
||||
> **Issues Gitea :** Projets/ObsiGate #4, #3, #2, #1 (même ordre)
|
||||
> **Référence d'analyse :** `Projets/ticktick-clone`, `docs/SYNCHRO-OBSIGATE.md` v1.1
|
||||
> (décisions §8 : vault dédié « TikTak », transport API REST, TikTak maître en conflit)
|
||||
|
||||
## Contexte
|
||||
|
||||
TikTak projette ses tâches/notes en fichiers Markdown dans un vault ObsiGate dédié
|
||||
via l'API REST fichiers. Côté ObsiGate, quatre manques bloquaient une synchronisation
|
||||
propre : réconciliation sans relire chaque fichier (#197), webhooks filtrables (#198),
|
||||
clé API à moindre privilège (#199), procédure de provisionnement (#200).
|
||||
|
||||
## #197 — Listes de fichiers : `mtime` + `size` (Gitea #4)
|
||||
|
||||
**Constat.** `GET /api/vault/{vault}/files` et l'outil MCP `list_all_files` exposaient
|
||||
déjà `size`/`modified`, mais pas `GET /api/vault/{vault}/paths` (seulement
|
||||
`vault/path/name/type`), ni `GET /api/browse/{vault}`, ni l'outil `list_directory`.
|
||||
|
||||
**Livré.**
|
||||
- `GET /api/vault/{vault}/paths` : chaque entrée fichier expose `size`, `mtime`
|
||||
(epoch) et `mtime_iso` (issus de l'index — source unique, même contrat que
|
||||
`list_all_files`) ; les répertoires portent `None`.
|
||||
- `GET /api/browse/{vault}` : `modified`/`modified_iso` sur les fichiers.
|
||||
- Outil MCP `list_directory` : `size`/`mtime`/`mtime_iso` sur les fichiers.
|
||||
- Cohérence read-after-write : `PUT /api/file/{vault}/save` rafraîchit l'index de
|
||||
façon synchrone (`update_single_file`), au lieu de dépendre du watcher.
|
||||
- **Bug latent corrigé au passage** : `update_single_file`/`remove_single_file`
|
||||
exigeaient un chemin absolu alors que toutes les routes leur passent un chemin
|
||||
relatif au vault — le rafraîchissement post-écriture était un no-op silencieux
|
||||
(masqué en prod par le watcher). Les deux fonctions acceptent désormais les deux
|
||||
formes.
|
||||
|
||||
**Réconciliation type (un seul appel) :** snapshot `{path: (mtime, size)}`, puis
|
||||
re-liste et comparaison — démontré par
|
||||
`tests/test_sync_lists.py::TestReconciliationScenario`.
|
||||
|
||||
## #198 — Webhooks : `vault` + filtre `vault` / `path_prefix` (Gitea #3)
|
||||
|
||||
**Constat.** Les charges utiles contenaient déjà `vault`, mais sans filtre un
|
||||
consommateur recevait tous les vaults. De plus `file_modified` (événement souscrit
|
||||
mais jamais émis) manquait pour tirer les retours de synchro.
|
||||
|
||||
**Livré.**
|
||||
- Filtres optionnels par webhook : `vault` (égalité exacte) et `path_prefix`
|
||||
(égalité ou sous-arbre — `Maison` matche `Maison/a.md`, pas `Maison2/a.md` ;
|
||||
sur renommage/déplacement, `old_path` **ou** `new_path` suffit pour être prévenu).
|
||||
Sans filtre : reçoit tout, comme avant (compatibilité).
|
||||
- `POST /api/webhooks` accepte `vault`/`path_prefix` ; `PATCH` les modifie
|
||||
(`""` réinitialise) ; `GET` les expose (`WebhookModel.vault/path_prefix`).
|
||||
- `PUT /api/file/{vault}/save` émet désormais `file_modified`
|
||||
(`{"vault", "path"}`) — la sauvegarde tableur/IA reste hors périmètre (documenté).
|
||||
- UI : champs filtre à la création + badge dans la liste ; aide intégrée FR/EN
|
||||
mise à jour (`file_modified` émis, filtrage documenté).
|
||||
- Filtrage pur et testable : `backend/webhooks.py::webhook_matches` ;
|
||||
`tests/test_webhook_filters.py` (matching, persistance, dispatch simulé :
|
||||
le webhook filtré sur « TikTak » ne reçoit que ses événements, avec
|
||||
`vault: "TikTak"` dans la charge).
|
||||
|
||||
## #199 — Clés API à portée limitée (Gitea #2)
|
||||
|
||||
**Constat.** Les clés longue durée portaient toutes les permissions du compte.
|
||||
|
||||
**Livré.**
|
||||
- Création : `POST /api/auth/tokens` accepte `vaults` (sous-ensemble des vaults
|
||||
du créateur, défaut = tous — `400` si inconnu, `403` si hors d'accès) et
|
||||
`scope` (`"full"` défaut | `"files"`).
|
||||
- Restriction vault : le JWT embarque les vaults autorisés → `check_vault_access`
|
||||
(REST) et `require_vault_access` (MCP/outils) refusent le reste en 403.
|
||||
- Périmètre `files` : fichiers (CRUD, listes, recherche, backups lecture/restauration),
|
||||
`GET /api/vaults`, `/api/search*`, `/mcp`. Tout le reste (admin, config, IA,
|
||||
webhooks, partages, **gestion des clés elle-même** — anti-escalade) → **403**
|
||||
via `TokenScopeMiddleware` (`backend/auth/scope.py`, stateless : claim lu sans
|
||||
vérification pour le refus, signature toujours vérifiée en aval).
|
||||
- MCP : `call_tool` + `propose_*` refusent les outils hors `FILES_SCOPE_TOOLS`
|
||||
(`token_scope_denied`), `list_tools` filtré pour ces tokens, ressources
|
||||
`vault://` couvertes.
|
||||
- UI : sélecteur de portée + champ vaults à la création, badge de portée et
|
||||
vaults dans la liste (i18n FR/EN).
|
||||
- Compatibilité : clés existantes (sans `scope`) = `full`, comportement inchangé.
|
||||
- Tests : `tests/test_scoped_tokens.py` (13 cas : création/validation, lecture/
|
||||
écriture OK, 403 autre vault, 403 hors périmètre, non-régression full, outils).
|
||||
|
||||
## #200 — Provisionnement du vault « TikTak » (Gitea #1, exploitation)
|
||||
|
||||
Opération unique côté hébergement (aucun code requis — endpoints déjà testés) :
|
||||
|
||||
```bash
|
||||
# 1. Dossier sur l'hôte
|
||||
mkdir -p /NFS/OBSIDIAN_DOC/TikTak
|
||||
|
||||
# 2. Montage conteneur (docker-compose.yml, puis recreate)
|
||||
# volumes:
|
||||
# - /NFS/OBSIDIAN_DOC/TikTak:/vaults/TikTak
|
||||
|
||||
# 3. Déclarer le vault SANS redémarrage (admin, persisté via data/vaults.json)
|
||||
curl -X POST $BASE/api/vaults/add -H "Authorization: Bearer $ADMIN" \
|
||||
-H 'Content-Type: application/json' -d '{"name":"TikTak","path":"/vaults/TikTak"}'
|
||||
|
||||
# 4. Octroyer le vault au compte de synchro (ACL par vault, UI admin ou API)
|
||||
|
||||
# 5. Clé à portée limitée pour TikTak (cf. #199)
|
||||
curl -X POST $BASE/api/auth/tokens -H "Authorization: Bearer $ADMIN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"TikTak sync","expiry":"never","vaults":["TikTak"],"scope":"files"}'
|
||||
|
||||
# 6. Webhook retour filtré (optionnel, phase 3 TikTak)
|
||||
curl -X POST $BASE/api/webhooks -H "Authorization: Bearer $ADMIN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"TikTak retour","url":"https://tiktak…/hooks/obsigate","events":["file_created","file_modified","file_deleted","file_renamed"],"vault":"TikTak"}'
|
||||
```
|
||||
|
||||
**Acceptation :** le vault « TikTak » est visible ; un `.md` créé via
|
||||
`POST /api/file/TikTak` (clé limitée, pas admin) est lu, cherché et édité sans erreur :
|
||||
|
||||
```bash
|
||||
KEY=<clé de l'étape 5>
|
||||
curl -X POST $BASE/api/file/TikTak -H "Authorization: Bearer $KEY" \
|
||||
-H 'Content-Type: application/json' -d '{"path":"Inbox/sonde.md","content":"# sonde\n"}'
|
||||
curl -s "$BASE/api/file/TikTak/raw?path=Inbox/sonde.md" -H "Authorization: Bearer $KEY"
|
||||
curl -s "$BASE/api/search?q=sonde&vault=TikTak" -H "Authorization: Bearer $KEY"
|
||||
```
|
||||
|
||||
## Fichiers touchés (rappel)
|
||||
|
||||
| # | Backend | Frontend | Tests |
|
||||
|---|---|---|---|
|
||||
| 197 | `services/search.py`, `services/vaults.py`, `schemas.py`, `indexer.py`, `routers/files_write.py`, `tools/service.py` | — | `tests/test_sync_lists.py` |
|
||||
| 198 | `webhooks.py`, `schemas.py`, `routers/webhooks.py`, `routers/files_write.py` | `js/config.js`, `index.html`, `locales/{fr,en}.json` | `tests/test_webhook_filters.py` |
|
||||
| 199 | `auth/jwt_handler.py`, `auth/middleware.py`, `auth/scope.py` (nouveau), `auth/router.py`, `main.py`, `tools/context.py`, `tools/registry.py`, `tools/api.py`, `mcp/server.py`, `openapi_docs.py` | `js/config.js`, `index.html`, `locales/{fr,en}.json` | `tests/test_scoped_tokens.py` |
|
||||
| 200 | — (runbook) | — | — (recette manuelle ci-dessus) |
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3424,6 +3424,13 @@
|
||||
|
||||
// Initialize outline/TOC for this document
|
||||
OutlineManager.init();
|
||||
|
||||
// Fusion #215 (ACC-1.2/1.3) — point d'extension : le module
|
||||
// draco-liens.js rend la section « Liens » de la note
|
||||
// (références croisées + « ouvrir dans TikTak » si projetée).
|
||||
document.dispatchEvent(new CustomEvent("obsigate:file-rendered", {
|
||||
detail: { vault: data.vault, path: data.path },
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/* 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; }
|
||||
@@ -0,0 +1,427 @@
|
||||
/* Alertes unifiées dracodev — Fusion #69 (chantier 2).
|
||||
*
|
||||
* 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 (« Connexion requise »)
|
||||
* avec un lien profond vers sa page native, jamais du vide déguisé.
|
||||
*
|
||||
* Sources réelles (audit #69, 2026-10-28) :
|
||||
* - 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. La section
|
||||
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
|
||||
*
|
||||
* 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] ;
|
||||
* 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).
|
||||
* 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.
|
||||
*
|
||||
* 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/"
|
||||
};
|
||||
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/"
|
||||
};
|
||||
var APP_LABELS = {
|
||||
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante"
|
||||
};
|
||||
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
|
||||
var EVENT_LABELS = {
|
||||
created: "créée", moved: "déplacée", closed: "fermée",
|
||||
reopened: "rouverte", commented: "commentée"
|
||||
};
|
||||
|
||||
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 s ? String(s).slice(0, 16) : "";
|
||||
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
|
||||
}
|
||||
|
||||
/* Résolution des bases. `env` permet les tests sans navigateur :
|
||||
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */
|
||||
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 = {};
|
||||
APP_ORDER.forEach(function (app) {
|
||||
var u = cleanUrl(env.barUrls && env.barUrls[app])
|
||||
|| cleanUrl(env.configUrls && env.configUrls[app]);
|
||||
if (!u && dev) u = DEV_URLS[app] || null;
|
||||
if (!u) {
|
||||
var href = null;
|
||||
if (env.tabHrefs && env.tabHrefs[app]) href = 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[app]) u = cleanUrl(env.dracoUrls[app]);
|
||||
if (!u) {
|
||||
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
|
||||
try {
|
||||
if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]);
|
||||
} catch (e) { /* rien */ }
|
||||
}
|
||||
if (!u) u = DEFAULT_URLS[app];
|
||||
urls[app] = 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
|
||||
};
|
||||
}
|
||||
|
||||
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 || ""))
|
||||
};
|
||||
});
|
||||
}
|
||||
/* Actives d'abord, puis date décroissante (sans date en dernier). */
|
||||
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;
|
||||
}
|
||||
|
||||
/* ---- Rendu (navigateur seulement) ---- */
|
||||
var panel = null, listEl = null, badgeTimer = null;
|
||||
|
||||
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 note = 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.");
|
||||
var close = el("button", "draco-alertes-close", "✕");
|
||||
close.setAttribute("aria-label", "Fermer");
|
||||
close.addEventListener("click", closePanel);
|
||||
head.appendChild(close);
|
||||
box.appendChild(head);
|
||||
box.appendChild(note);
|
||||
listEl = el("div", "draco-alertes-list");
|
||||
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") closePanel();
|
||||
});
|
||||
document.body.appendChild(ov);
|
||||
panel = ov;
|
||||
return ov;
|
||||
}
|
||||
function closePanel() { if (panel) panel.style.display = "none"; }
|
||||
|
||||
function renderSection(title, items, fallback) {
|
||||
var sec = el("section", "draco-alertes-section");
|
||||
sec.appendChild(el("h3", "", title + " (" + items.length + ")"));
|
||||
if (!items.length) {
|
||||
sec.appendChild(el("p", "draco-alertes-empty", fallback));
|
||||
return sec;
|
||||
}
|
||||
items.forEach(function (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));
|
||||
var meta = el("div", "draco-alertes-meta",
|
||||
APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") +
|
||||
" · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state);
|
||||
main.appendChild(meta);
|
||||
row.appendChild(main);
|
||||
sec.appendChild(row);
|
||||
});
|
||||
return sec;
|
||||
}
|
||||
function render(results, urls) {
|
||||
ensurePanel();
|
||||
listEl.textContent = "";
|
||||
var all = [];
|
||||
APP_ORDER.forEach(function (app) {
|
||||
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
|
||||
});
|
||||
var actives = sortAlerts(all.filter(function (a) { return a.active; }));
|
||||
var history = sortAlerts(all.filter(function (a) { return !a.active; }));
|
||||
listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives"));
|
||||
APP_ORDER.forEach(function (app) {
|
||||
var items = actives.filter(function (a) { return a.app === app; });
|
||||
if (results[app] && results[app].error) {
|
||||
var sec = renderSection(APP_LABELS[app], [], results[app].error);
|
||||
listEl.appendChild(sec);
|
||||
} else if (app === "obsigate") {
|
||||
listEl.appendChild(renderSection(APP_LABELS[app], [],
|
||||
"ObsiGate n'expose aucune notification entrante consultable " +
|
||||
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
|
||||
} else if (items.length) {
|
||||
listEl.appendChild(renderSection(APP_LABELS[app], items, ""));
|
||||
}
|
||||
});
|
||||
if (!actives.length) {
|
||||
listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables."));
|
||||
}
|
||||
listEl.appendChild(el("h3", "draco-alertes-group", "Historique"));
|
||||
APP_ORDER.forEach(function (app) {
|
||||
if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */
|
||||
if (app === "obsigate") {
|
||||
var link = el("div", "draco-alertes-empty");
|
||||
var a = el("a", "", "Ouvrir ObsiGate");
|
||||
a.href = urls.obsigate;
|
||||
link.appendChild(a);
|
||||
var sec = el("section", "draco-alertes-section");
|
||||
sec.appendChild(el("h3", "", "ObsiGate (0)"));
|
||||
sec.appendChild(link);
|
||||
listEl.appendChild(sec);
|
||||
return;
|
||||
}
|
||||
var items = history.filter(function (x) { return x.app === app; });
|
||||
listEl.appendChild(renderSection(APP_LABELS[app], items,
|
||||
"Aucun historique disponible pour cette application."));
|
||||
});
|
||||
}
|
||||
|
||||
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 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) }; })
|
||||
};
|
||||
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]; });
|
||||
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 all = [];
|
||||
APP_ORDER.forEach(function (app) {
|
||||
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
|
||||
});
|
||||
setBadge(countActive(all));
|
||||
return countActive(all);
|
||||
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
|
||||
}
|
||||
function openPanel() {
|
||||
ensurePanel();
|
||||
panel.style.display = "flex";
|
||||
listEl.textContent = "";
|
||||
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
|
||||
loadAll().then(function (r) {
|
||||
render(r.results, r.urls);
|
||||
var all = [];
|
||||
APP_ORDER.forEach(function (app) {
|
||||
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
|
||||
});
|
||||
setBadge(countActive(all));
|
||||
}).catch(function () {
|
||||
render({ obsigate: { items: [] } }, resolveUrls(browserEnv()));
|
||||
});
|
||||
}
|
||||
|
||||
var api = {
|
||||
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
|
||||
_test: {
|
||||
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
|
||||
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
|
||||
sortAlerts: sortAlerts, countActive: countActive,
|
||||
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS
|
||||
}
|
||||
};
|
||||
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). */
|
||||
if (typeof document !== "undefined" && document.addEventListener) {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
if (badgeTimer) clearTimeout(badgeTimer);
|
||||
badgeTimer = setTimeout(refreshBadge, 300);
|
||||
});
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,196 @@
|
||||
/* 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. */
|
||||
|
||||
.draco-bar {
|
||||
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
|
||||
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
|
||||
--agent: #c2437e; --danger: #cf4444;
|
||||
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;
|
||||
background: var(--surface); border-bottom: 1px solid var(--line);
|
||||
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
|
||||
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
|
||||
}
|
||||
[data-theme="dark"] .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
|
||||
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
|
||||
|
||||
.draco-bar-zone { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.draco-bar-centre { justify-content: center; }
|
||||
.draco-bar-droite { justify-content: flex-end; }
|
||||
|
||||
/* Marque — 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; }
|
||||
.draco-apptab {
|
||||
position: relative; display: flex; align-items: center; gap: 8px;
|
||||
height: 44px; padding: 0 9px; border-radius: 12px;
|
||||
color: var(--ink); text-decoration: none; background: transparent;
|
||||
transition: background .18s ease;
|
||||
}
|
||||
.draco-apptab:hover { background: var(--surface-2); }
|
||||
.draco-apptab-ico {
|
||||
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
|
||||
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
|
||||
}
|
||||
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
|
||||
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
|
||||
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
|
||||
.draco-apptab[data-active="true"] { background: var(--surface-3); box-shadow: inset 0 -2px 0 var(--app, var(--agent)); }
|
||||
.draco-apptab[data-active="true"] .draco-apptab-txt { display: flex; }
|
||||
|
||||
/* Pastilles compteurs — petites, rouges, coin bas-droit (standard v7.1) */
|
||||
.draco-badge {
|
||||
position: absolute; right: -4px; bottom: -4px; min-width: 17px; height: 17px;
|
||||
padding: 0 4px; border-radius: 9px; box-sizing: border-box;
|
||||
background: #e5484d; color: #fff; font-size: 10.5px; font-weight: 700;
|
||||
display: grid; place-items: center; border: 2px solid var(--surface);
|
||||
}
|
||||
.draco-badge[hidden] { display: none; }
|
||||
|
||||
/* Recherche */
|
||||
.draco-search {
|
||||
display: flex; align-items: center; gap: 8px; width: 100%; height: 38px;
|
||||
padding: 0 12px; border-radius: 10px; border: 1px solid var(--line);
|
||||
background: var(--surface-3); color: var(--ink-3); cursor: pointer; font: inherit;
|
||||
}
|
||||
.draco-search kbd {
|
||||
margin-left: auto; font: 600 11px system-ui; color: var(--ink-2);
|
||||
border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px;
|
||||
}
|
||||
|
||||
/* Domaines — contrôle segmenté */
|
||||
.draco-domaines { display: flex; align-items: center; gap: 2px; background: var(--surface-3); border-radius: 10px; padding: 3px; }
|
||||
.draco-domaines-lbl { font-size: 10.5px; letter-spacing: .06em; color: var(--ink-3); padding: 0 6px; text-transform: uppercase; }
|
||||
.draco-domaines button {
|
||||
border: 0; background: transparent; color: var(--ink-2); font: inherit; font-size: 13px;
|
||||
height: 30px; padding: 0 10px; border-radius: 8px; cursor: pointer;
|
||||
}
|
||||
.draco-domaines button[data-on="true"] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 3px rgb(0 0 0 / .18); }
|
||||
|
||||
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
|
||||
.draco-alertes {
|
||||
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
|
||||
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
|
||||
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
|
||||
}
|
||||
|
||||
/* Spark */
|
||||
.draco-spark {
|
||||
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
|
||||
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
|
||||
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
|
||||
}
|
||||
.draco-spark .draco-badge { border-color: var(--agent); }
|
||||
|
||||
.draco-icon-btn {
|
||||
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
|
||||
background: transparent; color: var(--ink-2); font: inherit; cursor: pointer;
|
||||
}
|
||||
.draco-icon-btn:hover { background: var(--surface-2); }
|
||||
.draco-avatar {
|
||||
width: 34px; height: 34px; border: 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) */
|
||||
.draco-bar-rappel {
|
||||
position: fixed; top: 0; left: 16px; z-index: 60; cursor: pointer;
|
||||
background: var(--surface, #1a1c1a); border: 1px solid var(--line, #2e302c); border-top: 0;
|
||||
border-radius: 0 0 10px 10px; padding: 5px 12px; font: 600 12px system-ui; color: var(--ink-2, #a6aba2);
|
||||
}
|
||||
|
||||
/* Paliers — les zones latérales cèdent, la barre ne casse jamais */
|
||||
@media (max-width: 1440px) {
|
||||
.draco-brand-name, .draco-domaines-lbl { display: none; }
|
||||
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
|
||||
.draco-search { width: 38px; padding: 0; justify-content: center; }
|
||||
.draco-bar { grid-template-columns: auto auto 1fr; }
|
||||
.draco-bar-droite { justify-content: flex-end; }
|
||||
}
|
||||
@media (max-width: 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"] { display: flex; }
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
/* 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
|
||||
* proscrits dans tout le programme).
|
||||
*
|
||||
* Configuration attendue avant le chargement :
|
||||
* window.DRACO_BAR_CONFIG = {
|
||||
* current: "tiktak", // app courante
|
||||
* domain: "pro", // domaine actif
|
||||
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
||||
* 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).
|
||||
* Les clés agent et spark ne correspondent à aucun onglet de la barre :
|
||||
* elles sont résolues et exposées via DRACO_BAR.urls pour le panneau
|
||||
* Spark (panneau-agent), qui suit le même contrat d'injection.
|
||||
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
||||
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
|
||||
* (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||
* 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) défaut officiel
|
||||
* prod https://agent.dracodev.net/ (il n'y a pas d'onglet agent, donc
|
||||
* pas d'étape « href rendu »). 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) {
|
||||
/* 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, .draco-apps-item").forEach(function (t) {
|
||||
if (t.dataset.app) t.dataset.active = t.dataset.app === app ? "true" : "false";
|
||||
});
|
||||
}
|
||||
function setDomain(dom) {
|
||||
document.querySelectorAll('.draco-domaines [data-dom]').forEach(function (b) {
|
||||
b.dataset.on = b.dataset.dom === dom ? "true" : "false";
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 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) {
|
||||
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);
|
||||
relay(act, el, ev);
|
||||
});
|
||||
|
||||
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);
|
||||
})();
|
||||
@@ -0,0 +1,193 @@
|
||||
/* Section « Liens » des notes — Fusion #215 (ACC-1.2 + ACC-1.3).
|
||||
*
|
||||
* Module autonome (vanilla, zéro dépendance, zéro build) : il écoute
|
||||
* l'événement « obsigate:file-rendered » émis par renderFile
|
||||
* (frontend/app.js) et rend sa section à la fin de #content-area :
|
||||
*
|
||||
* - ACC-1.2 : si la note est une projection TikTak (frontmatter
|
||||
* « tiktak_id » écrit par la synchro TikTak ↔ ObsiGate), un bandeau
|
||||
* « Tâche TikTak projetée » avec le bouton « Ouvrir dans TikTak »
|
||||
* (lien profond https://tiktak.dracodev.net/?task=<id>) ;
|
||||
* - ACC-1.3 : les références croisées de la note (GET /api/refs),
|
||||
* ajout par collage d'une URL profonde, retrait en un clic.
|
||||
*
|
||||
* Textes via window.t (i18n FR/EN — clés « draco.links.* » des locales).
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var COLORS = {
|
||||
obsigate: "#7a5af5",
|
||||
flowdeck: "#2f6fed",
|
||||
tiktak: "#e8762c",
|
||||
kanbante: "#0d9488",
|
||||
};
|
||||
|
||||
/* 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);
|
||||
return v && v !== key ? v : fallback;
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
var tok = null;
|
||||
try {
|
||||
tok = sessionStorage.getItem("obsigate_access_token");
|
||||
} catch (e) { /* stockage indisponible */ }
|
||||
return tok ? { Authorization: "Bearer " + tok } : {};
|
||||
}
|
||||
|
||||
async function api(path, opts) {
|
||||
var options = opts || {};
|
||||
options.headers = Object.assign(
|
||||
{ "Content-Type": "application/json" },
|
||||
authHeaders(),
|
||||
options.headers || {}
|
||||
);
|
||||
options.credentials = "same-origin";
|
||||
var resp = await fetch(path, options);
|
||||
if (!resp.ok) {
|
||||
var detail = "";
|
||||
try {
|
||||
detail = (await resp.json()).detail || "";
|
||||
} catch (e) { /* corps non JSON */ }
|
||||
throw new Error(detail || "HTTP " + resp.status);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
function el(tag, cls, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (cls) node.className = cls;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
async function render(vault, path) {
|
||||
var area = document.getElementById("content-area");
|
||||
if (!area || !vault || !path) return;
|
||||
var old = document.getElementById("draco-liens");
|
||||
if (old) old.remove();
|
||||
|
||||
var section = el("section", "draco-liens");
|
||||
section.id = "draco-liens";
|
||||
var title = el("h3", "draco-liens-title", t("draco.links.title", "Liens"));
|
||||
section.appendChild(title);
|
||||
area.appendChild(section);
|
||||
|
||||
// ACC-1.2 — note projetée depuis TikTak ?
|
||||
if (/\.md$/i.test(path)) {
|
||||
try {
|
||||
var rawUrl = "/api/file/" + encodeURIComponent(vault) + "/raw?path=" +
|
||||
encodeURIComponent(path);
|
||||
var rawData = await api(rawUrl);
|
||||
var m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(rawData.raw || "");
|
||||
var tid = m && /^tiktak_id:\s*(\d+)\s*$/m.exec(m[1]);
|
||||
if (tid) {
|
||||
var bandeau = el("div", "draco-liens-projete");
|
||||
bandeau.appendChild(el("span", "draco-liens-badge",
|
||||
t("draco.links.projected", "Tâche TikTak projetée")));
|
||||
var ouvrir = el("a", "btn-action draco-liens-ouvrir",
|
||||
t("draco.links.openInTikTak", "Ouvrir dans TikTak"));
|
||||
ouvrir.href = baseTikTak() + "/?task=" + tid[1];
|
||||
ouvrir.target = "_blank";
|
||||
ouvrir.rel = "noopener";
|
||||
bandeau.appendChild(ouvrir);
|
||||
section.appendChild(bandeau);
|
||||
}
|
||||
} catch (e) { /* la détection est opportuniste : silence */ }
|
||||
}
|
||||
|
||||
// ACC-1.3 — références croisées de la note
|
||||
var list = el("ul", "draco-liens-list");
|
||||
section.appendChild(list);
|
||||
var erreur = el("p", "draco-liens-erreur");
|
||||
erreur.hidden = true;
|
||||
section.appendChild(erreur);
|
||||
|
||||
async function refresh() {
|
||||
list.innerHTML = "";
|
||||
try {
|
||||
var data = await api("/api/refs?vault=" + encodeURIComponent(vault) +
|
||||
"&path=" + encodeURIComponent(path));
|
||||
(data.refs || []).forEach(function (r) {
|
||||
var li = el("li", "draco-liens-item");
|
||||
var dot = el("span", "draco-ref-dot");
|
||||
dot.style.background = COLORS[r.target_app] || "#888";
|
||||
li.appendChild(dot);
|
||||
var a = el("a", "", r.label);
|
||||
a.href = r.target_url;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener";
|
||||
li.appendChild(a);
|
||||
var del = el("button", "draco-liens-del", "✕");
|
||||
del.type = "button";
|
||||
del.title = t("draco.links.remove", "Retirer le lien");
|
||||
del.addEventListener("click", async function () {
|
||||
try {
|
||||
await api("/api/refs/" + encodeURIComponent(r.id), { method: "DELETE" });
|
||||
refresh();
|
||||
} catch (e) {
|
||||
erreur.textContent = e.message;
|
||||
erreur.hidden = false;
|
||||
}
|
||||
});
|
||||
li.appendChild(del);
|
||||
list.appendChild(li);
|
||||
});
|
||||
} catch (e) { /* section facultative : ne jamais casser la note */ }
|
||||
}
|
||||
|
||||
var form = el("form", "draco-liens-form");
|
||||
var urlInput = el("input");
|
||||
urlInput.type = "url";
|
||||
urlInput.required = true;
|
||||
urlInput.placeholder = t("draco.links.urlPlaceholder",
|
||||
"Coller une URL profonde (Flowdeck, TikTak, Gitea, ObsiGate)…");
|
||||
var labelInput = el("input");
|
||||
labelInput.type = "text";
|
||||
labelInput.placeholder = t("draco.links.labelPlaceholder", "Libellé (optionnel)");
|
||||
var submit = el("button", "btn-action", t("draco.links.add", "Lier"));
|
||||
submit.type = "submit";
|
||||
form.appendChild(urlInput);
|
||||
form.appendChild(labelInput);
|
||||
form.appendChild(submit);
|
||||
form.addEventListener("submit", async function (ev) {
|
||||
ev.preventDefault();
|
||||
erreur.hidden = true;
|
||||
try {
|
||||
await api("/api/refs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
vault: vault,
|
||||
path: path,
|
||||
url: urlInput.value.trim(),
|
||||
label: labelInput.value.trim() || null,
|
||||
}),
|
||||
});
|
||||
urlInput.value = "";
|
||||
labelInput.value = "";
|
||||
refresh();
|
||||
} catch (e) {
|
||||
erreur.textContent = e.message;
|
||||
erreur.hidden = false;
|
||||
}
|
||||
});
|
||||
section.appendChild(form);
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
document.addEventListener("obsigate:file-rendered", function (ev) {
|
||||
if (ev.detail) render(ev.detail.vault, ev.detail.path);
|
||||
});
|
||||
})();
|
||||
@@ -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
@@ -88,6 +88,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
.preview-content p { margin: 6px 0; }
|
||||
.preview-content ul, .preview-content ol { margin: 6px 0; padding-left: 24px; }
|
||||
.preview-content li { margin: 2px 0; }
|
||||
.preview-content .task-list-item { list-style: none; margin-left: -1.2em; }
|
||||
.preview-content .task-list-item input[type="checkbox"] { margin-right: 6px; }
|
||||
.preview-content code { background: var(--bg3); padding: 1px 5px; border-radius: 3px; font-family: var(--mono); font-size: 12px; color: var(--ai); }
|
||||
.preview-content pre { background: var(--bg3); padding: 12px 16px; border-radius: var(--R); overflow-x: auto; margin: 10px 0; border: 1px solid var(--border); }
|
||||
.preview-content pre code { background: none; padding: 0; color: var(--text); }
|
||||
@@ -134,7 +136,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
.qi-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
/* Inline form (table dims, code lang) */
|
||||
.inline-form { position: absolute; z-index: 195; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 8px 10px; min-width: 200px; }
|
||||
.inline-form { position: fixed; z-index: 195; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 8px 10px; min-width: 200px; }
|
||||
.inline-form.on { display: block; animation: fadeIn 100ms; }
|
||||
.inline-form-label { font-size: 10px; font-weight: 600; color: var(--text3); text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 6px; }
|
||||
.inline-form-row { display: flex; gap: 6px; align-items: center; margin-bottom: 5px; }
|
||||
@@ -148,7 +150,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
.inline-form-actions button.ok:hover { background: #5a8fc5; }
|
||||
|
||||
/* Lang picker (in QI menu style) */
|
||||
.lang-picker { position: absolute; z-index: 195; width: 200px; max-height: 320px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 3px; }
|
||||
.lang-picker { position: fixed; z-index: 195; width: 200px; max-height: 320px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 3px; }
|
||||
.lang-picker.on { display: block; animation: fadeIn 100ms; }
|
||||
.lang-item { display: flex; align-items: center; gap: 7px; padding: 6px 9px; font-size: 12px; color: var(--text2); cursor: pointer; border-radius: var(--r); font-family: var(--mono); }
|
||||
.lang-item:hover, .lang-item.sel { background: var(--bg-hover); color: var(--text); }
|
||||
@@ -498,6 +500,10 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
ta.value = val().substring(0, p.s) + text + val().substring(p.e);
|
||||
var np = cursorOffset != null ? p.s + cursorOffset : p.s + text.length;
|
||||
setPos(np);
|
||||
// Mutation programmatique : pas d'evenement 'input' → sans autoHeight/updateLineNumbers,
|
||||
// sinon la textarea reste d'une ligne quand on insère un tableau/code.
|
||||
autoHeight();
|
||||
updateLineNumbers();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
@@ -507,6 +513,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
var sel = val().substring(p.s, p.e);
|
||||
ta.value = val().substring(0, p.s) + before + sel + (after || before) + val().substring(p.e);
|
||||
setPos(p.s + before.length + sel.length + (after || before).length);
|
||||
autoHeight();
|
||||
updateLineNumbers();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
@@ -764,6 +772,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
// ---- Markdown Preview (server-side rendering via ObsiGate API) ----
|
||||
var previewTimer = null;
|
||||
var _previewLoading = false;
|
||||
var _previewDirty = false;
|
||||
function schedulePreview() {
|
||||
if (!previewVisible) return;
|
||||
clearTimeout(previewTimer);
|
||||
@@ -772,7 +781,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
|
||||
function renderPreview() {
|
||||
if (!previewVisible) return;
|
||||
if (_previewLoading) return;
|
||||
if (_previewLoading) { _previewDirty = true; return; }
|
||||
// Demo mode: fallback to client-side marked
|
||||
if (!fileVault || !filePath) {
|
||||
try {
|
||||
@@ -784,7 +793,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
}
|
||||
// Save first, then fetch server-rendered HTML
|
||||
_previewLoading = true;
|
||||
previewContent.innerHTML = '<div class="preview-loading"><span class="spin"></span> Rendering...</div>';
|
||||
// Ne jamais effacer le rendu affiché : un update garde l'ancien contenu
|
||||
// jusqu'à l'arrivée du nouveau (sinon l'aperçu flashe à chaque frappe).
|
||||
if (!previewContent.firstChild) {
|
||||
previewContent.innerHTML = '<div class="preview-loading"><span class="spin"></span> Rendering...</div>';
|
||||
}
|
||||
var content = val();
|
||||
fetch('/api/file/' + encodeURIComponent(fileVault) + '/save?path=' + encodeURIComponent(filePath) + '&backup=false', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
@@ -799,6 +812,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
previewContent.innerHTML = data.html || '<div class="preview-loading">Empty document</div>';
|
||||
processPreviewBlocks();
|
||||
_previewLoading = false;
|
||||
if (_previewDirty) { _previewDirty = false; schedulePreview(); }
|
||||
}).catch(function(e) {
|
||||
previewContent.innerHTML = '<div class="preview-loading">Erreur de rendu</div>';
|
||||
_previewLoading = false;
|
||||
@@ -1082,8 +1096,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
ifCols.value = '3'; ifRows.value = '3';
|
||||
var pt = caretToPixel(null);
|
||||
var wr = editorWrap.getBoundingClientRect();
|
||||
inlineForm.style.left = (wr.left + pt.x) + 'px';
|
||||
inlineForm.style.top = (wr.top + pt.y + 8) + 'px';
|
||||
var _ftop = wr.top + pt.y + 8, _fleft = wr.left + pt.x;
|
||||
if (_ftop + 130 > window.innerHeight) _ftop = window.innerHeight - 140;
|
||||
if (_fleft + 210 > window.innerWidth) _fleft = window.innerWidth - 220;
|
||||
inlineForm.style.left = _fleft + 'px';
|
||||
inlineForm.style.top = _ftop + 'px';
|
||||
inlineForm.classList.add('on');
|
||||
ifCols.focus(); ifCols.select();
|
||||
}
|
||||
@@ -1130,8 +1147,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
langPicker.innerHTML = html;
|
||||
var pt = caretToPixel(null);
|
||||
var wr = editorWrap.getBoundingClientRect();
|
||||
langPicker.style.left = (wr.left + pt.x) + 'px';
|
||||
langPicker.style.top = (wr.top + pt.y + 8) + 'px';
|
||||
var _ltop = wr.top + pt.y + 8, _lleft = wr.left + pt.x;
|
||||
if (_ltop + 320 > window.innerHeight) _ltop = window.innerHeight - 330;
|
||||
if (_lleft + 200 > window.innerWidth) _lleft = window.innerWidth - 210;
|
||||
langPicker.style.left = _lleft + 'px';
|
||||
langPicker.style.top = _ltop + 'px';
|
||||
langPicker.classList.add('on');
|
||||
// Click handler
|
||||
langPicker.querySelectorAll('.lang-item').forEach(function(item) {
|
||||
@@ -1428,7 +1448,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
slashSearch.addEventListener('input', function() { filterSlash(slashSearch.value); });
|
||||
slashMenu.addEventListener('click', function(e) {
|
||||
var item = e.target.closest('.slash-item');
|
||||
if (item) selectSlashAction(item.getAttribute('data-action'));
|
||||
if (item) { e.stopPropagation(); selectSlashAction(item.getAttribute('data-action')); }
|
||||
});
|
||||
function navSlash(dir) {
|
||||
var vis = getVisibleSlash(); if (vis.length === 0) return;
|
||||
@@ -1452,6 +1472,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
||||
document.getElementById('qi-btn').addEventListener('click', function(e) { e.stopPropagation(); toggleQI(); });
|
||||
qiMenu.addEventListener('click', function(e) {
|
||||
var item = e.target.closest('.qi-item'); if (!item) return;
|
||||
e.stopPropagation();
|
||||
var action = item.getAttribute('data-qi');
|
||||
qiMenu.classList.remove('on');
|
||||
selectSlashAction(action);
|
||||
|
||||
+183
-12
@@ -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; }
|
||||
@@ -75,6 +75,9 @@
|
||||
@media (prefers-reduced-motion: reduce) { .bs-spinner { animation-duration: 1.8s; } }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
<link rel="stylesheet" href="/static/draco-barre.css" />
|
||||
<link rel="stylesheet" href="/static/draco-alertes.css" />
|
||||
<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"
|
||||
@@ -222,7 +225,7 @@
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="has-draco-bar">
|
||||
<!-- Boot splash : visible tant que l'app n'a pas booté avec son CSS.
|
||||
Sur desktop, la page embarquée (tauri://) n'a pas /static/style.css →
|
||||
sans ce splash, le squelette HTML brut serait visible pendant le
|
||||
@@ -329,6 +332,138 @@
|
||||
</div>
|
||||
|
||||
<!-- Main Application (wrapped for show/hide) -->
|
||||
<!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1).
|
||||
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.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. -->
|
||||
<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="/" 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">
|
||||
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
|
||||
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
|
||||
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
|
||||
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
|
||||
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
|
||||
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
||||
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="draco-bar-zone draco-bar-centre">
|
||||
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
|
||||
<span class="draco-search-ico">⌕</span>
|
||||
<span class="draco-search-txt">Rechercher partout…</span>
|
||||
<kbd>⌘K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
<div class="draco-bar-zone draco-bar-droite">
|
||||
<div class="draco-domaines" role="group" aria-label="Domaine">
|
||||
<span class="draco-domaines-lbl">Domaine</span>
|
||||
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
|
||||
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
|
||||
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
|
||||
</div>
|
||||
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
|
||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||
<span class="draco-alertes-txt">Alertes</span>
|
||||
<span class="draco-badge" data-badge="alertes" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
|
||||
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
||||
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-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>
|
||||
<script>
|
||||
/* Configuration ObsiGate de la barre commune (Fusion #215).
|
||||
Phase 1 : navigation entre apps et domaine mémorisé
|
||||
localement ; la recherche focus la recherche native
|
||||
d'ObsiGate ; Alertes unifiées : 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"; } })(),
|
||||
counts: {},
|
||||
onAction: function (act, el) {
|
||||
var dire = function (m) {
|
||||
var d = document.createElement("div");
|
||||
d.textContent = m;
|
||||
d.style.cssText = "position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:20000;background:#161b22;color:#e6edf3;padding:10px 16px;border-radius:10px;font:500 13.5px system-ui;box-shadow:0 6px 24px rgb(0 0 0/.35)";
|
||||
document.body.appendChild(d);
|
||||
setTimeout(function () { d.remove(); }, 3400);
|
||||
};
|
||||
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
|
||||
if (act === "rail") {
|
||||
// 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 = "";
|
||||
el.hidden = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
/* Avatar : initiale de l'utilisateur courant (best effort). */
|
||||
window.addEventListener("DOMContentLoaded", function () {
|
||||
var tok = null;
|
||||
try { tok = sessionStorage.getItem("obsigate_access_token"); } catch (e) { }
|
||||
fetch("/api/auth/me", {
|
||||
credentials: "same-origin",
|
||||
headers: tok ? { Authorization: "Bearer " + tok } : {}
|
||||
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (u) {
|
||||
var nom = u && (u.display_name || u.username);
|
||||
if (nom) {
|
||||
var av = document.getElementById("draco-avatar");
|
||||
if (av) { av.textContent = nom.charAt(0).toUpperCase(); av.title = nom; }
|
||||
}
|
||||
}).catch(function () { /* avatar générique conservé */ });
|
||||
});
|
||||
</script>
|
||||
<div id="app" class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
@@ -2576,6 +2711,18 @@
|
||||
<option value="365d" data-i18n="config.token_expiry_365d">1 an</option>
|
||||
<option value="never" data-i18n="config.token_expiry_never">Sans fin</option>
|
||||
</select>
|
||||
<select id="token-scope-select" class="config-input" style="width: 150px" title="Portée">
|
||||
<option value="full" data-i18n="config.token_scope_full">Accès complet</option>
|
||||
<option value="files" data-i18n="config.token_scope_files">Fichiers seuls</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
id="token-vaults-input"
|
||||
data-i18n-placeholder="config.token_vaults_placeholder"
|
||||
placeholder="Vaults (vide = tous, ex : TikTak)"
|
||||
class="config-input"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<button class="config-btn-add" id="token-create-btn" data-i18n="config.token_create">Créer une clé</button>
|
||||
</div>
|
||||
<div id="token-secret-area" class="token-secret-area hidden">
|
||||
@@ -2633,6 +2780,22 @@
|
||||
class="config-input"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="webhook-vault-input"
|
||||
data-i18n-placeholder="config.webhook_vault_placeholder"
|
||||
placeholder="Vault (vide = tous)"
|
||||
class="config-input"
|
||||
style="width: 130px"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="webhook-prefix-input"
|
||||
data-i18n-placeholder="config.webhook_prefix_placeholder"
|
||||
placeholder="Préfixe (vide = tout)"
|
||||
class="config-input"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<button id="webhook-add-btn"
|
||||
class="config-btn config-btn-add" data-i18n="config.add_webhook">
|
||||
Ajouter
|
||||
@@ -5114,9 +5277,11 @@
|
||||
<p>
|
||||
<em
|
||||
>Note : l'événement
|
||||
<code>file_modified</code> est réservé pour
|
||||
usage futur. Les modifications de contenu
|
||||
via l'éditeur ne déclenchent pas encore de
|
||||
<code>file_modified</code> est émis par
|
||||
la sauvegarde texte
|
||||
(<code>PUT /api/file/{vault}/save</code>).
|
||||
Les autres écritures (tableur, IA) ne
|
||||
déclenchent pas encore de
|
||||
webhook.</em
|
||||
>
|
||||
</p>
|
||||
@@ -5289,13 +5454,15 @@ curl -X POST https://votre-serveur.com/webhook \
|
||||
REST sont prises en compte
|
||||
</li>
|
||||
<li>
|
||||
<code>file_modified</code> n'est pas encore
|
||||
dispatché (réservé pour version future)
|
||||
<code>file_modified</code> est émis par la
|
||||
sauvegarde texte
|
||||
(<code>PUT /api/file/{vault}/save</code>)
|
||||
</li>
|
||||
<li data-i18n="auto.1d3330cc">Pas de retry automatique en cas d'échec</li>
|
||||
<li data-i18n="auto.4dfa6a25">
|
||||
Pas de filtrage par vault — un webhook
|
||||
écoute toutes les vaults
|
||||
Filtrage optionnel par vault et préfixe de
|
||||
chemin — sans filtre, un webhook écoute
|
||||
toutes les vaults
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -5899,5 +6066,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>
|
||||
|
||||
@@ -121,6 +121,25 @@ const AuthManager = {
|
||||
if (tokenData.user) {
|
||||
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
|
||||
}
|
||||
this._honorReturnTo();
|
||||
},
|
||||
|
||||
// Fusion ACC-3.1 (#216) : après connexion chez l'émettrice SSO,
|
||||
// repartir vers l'application demanderesse. Le return_to est
|
||||
// re-validé ici (https + domaine dracodev.net, jamais l'hôte
|
||||
// courant) en plus de la validation serveur de /sso/authorize.
|
||||
_honorReturnTo() {
|
||||
try {
|
||||
const rt = new URLSearchParams(window.location.search).get("return_to");
|
||||
if (!rt) return;
|
||||
const u = new URL(rt);
|
||||
const okHost = u.hostname === "dracodev.net"
|
||||
|| u.hostname.endsWith(".dracodev.net");
|
||||
if (u.protocol === "https:" && okHost
|
||||
&& u.hostname !== window.location.hostname) {
|
||||
window.location.replace(rt);
|
||||
}
|
||||
} catch (e) { /* return_to illisible : on reste sur place */ }
|
||||
},
|
||||
|
||||
getToken() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user