Compare commits

..
Author SHA1 Message Date
bruno 75139fbf6b Recherche partout (#68) : palette de la barre -> /recherche
CI / lint (pull_request) Failing after 3s
CI / security (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 12:18:02 -04:00
bruno 3a4ee20d74 Recherche partout (#68) : tests-js 2026-10-28 12:17:55 -04:00
bruno 92e636477f Recherche partout (#68) : tests-py 2026-10-28 12:17:49 -04:00
bruno 67a486313f Recherche partout (#68) : routes+middleware 2026-10-28 12:17:43 -04:00
bruno 1d6ea4728f Recherche partout (#68) : CORS 2026-10-28 12:17:38 -04:00
bruno d134ad49f1 Recherche partout (#68) : gabarit 2026-10-28 12:17:33 -04:00
bruno e0b39c6570 Recherche partout (#68) : CSS 2026-10-28 12:17:29 -04:00
bruno ec20573de0 Recherche partout (#68) : JS 2026-10-28 12:17:25 -04:00
bruno 3b8402374a Bump 2.81.3 (CHANGELOG.md)
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 10:39:13 -04:00
bruno 6635defb2e Bump 2.81.3 (docs/ROADMAP.md)
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 10:39:09 -04:00
bruno 7fb073d462 Bump 2.81.3 (desktop/Cargo.lock)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 10:39:06 -04:00
bruno 2e59f8786b Bump 2.81.3 (desktop/Cargo.toml)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 10:39:03 -04:00
bruno ba6069962a Bump 2.81.3 (desktop/tauri.conf.json)
CI / lint (push) Failing after 0s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 10:39:00 -04:00
bruno 375de59e28 Bump 2.81.3 (package.json)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 10:38:57 -04:00
bruno 3b84a86b8f Bump 2.81.3 (VERSION)
CI / lint (push) Failing after 0s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 10:38:54 -04:00
bruno a5dac2349d Merge pull request 'barre-apps v0.1.6 : vendange (dérivation -dev, avatar compte) — menu natif préservé' (#37) from barre-apps-v0.1.6-obsigate into main
CI / lint (push) Failing after 3s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 1m59s
2026-10-28 10:34:21 -04:00
bruno da6de52198 barre-apps v0.1.6 : CSS vendange (menus, avatar bouton)
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Failing after 0s
2026-10-28 10:32:06 -04:00
bruno 87016e5581 barre-apps v0.1.6 : JS vendange (derivation -dev, menus) 2026-10-28 10:32:01 -04:00
bruno e0e45ac048 barre-apps v0.1.6 : fragment inline (avatar bouton compte, aria menu) - menu natif et DRACO_URLS inchanges 2026-10-28 10:31:55 -04:00
bruno 1b3ba04b5b chore(release): v2.81.2 — CHANGELOG
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:42 -04:00
bruno 5c5180336b chore(release): v2.81.2 — synchroniser docs/ROADMAP.md
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:40 -04:00
bruno 73bf863e40 chore(release): v2.81.2 — synchroniser desktop/Cargo.lock
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:37 -04:00
bruno 06d0e354fe chore(release): v2.81.2 — synchroniser desktop/Cargo.toml
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:32 -04:00
bruno a01fba6602 chore(release): v2.81.2 — synchroniser desktop/tauri.conf.json
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:29 -04:00
bruno 54ebf2365c chore(release): v2.81.2 — synchroniser package.json
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:26 -04:00
bruno 24eaa7f9df chore(release): v2.81.2 (panneau Spark / console, PR #35)
CI / security (push) Successful in 2m0s
CI / lint (push) Successful in 3m12s
CI / test (push) Failing after 0s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:30:24 -04:00
bruno 8e3eb3a9d7 Merge pull request 'fix(sidebar): style console, statut dans le titre, composer console, synchro domaine' (#35) from fix/sidebar-console-style-domaine into main
CI / security (push) Failing after 0s
CI / lint (push) Successful in 3m11s
CI / test (push) Failing after 0s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 09:28:50 -04:00
bruno 01ce8b8ec3 Merge pull request 'chore(release): version 2.81.1 (correctifs CI + icone Secrets)' (#36) from fix/version-2.81.1 into main
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 2m7s
2026-10-28 09:19:57 -04:00
bruno 5dd6809ef0 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets)
CI / security (pull_request) Failing after 1s
CI / lint (pull_request) Successful in 3m17s
CI / test (pull_request) Successful in 5m26s
CI / build (pull_request) Failing after 1s
CI / e2e (pull_request) Skipped
2026-10-28 09:19:32 -04:00
bruno 7b8bc52650 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:28 -04:00
bruno 1758c3b6d2 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:24 -04:00
bruno 6669633902 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:20 -04:00
bruno a65d282c0b chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:17 -04:00
bruno 524b43cc40 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:13 -04:00
bruno 4e0b27c4d7 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:10 -04:00
bruno c6d7acfd99 chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:07 -04:00
bruno bf941c71ec chore(release): bump version 2.81.1 (correctifs CI + icone Secrets) 2026-10-28 09:19:05 -04:00
bruno dcec510451 fix(sidebar): CSS composer console, statut dans le titre, pied épuré
CI / lint (pull_request) Failing after 1s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 08:49:05 -04:00
bruno cb468b0f3c fix(sidebar): style console, statut dans le titre, composer console, synchro domaine 2026-10-28 08:48:54 -04:00
bruno 6713510ce9 Merge pull request 'fix(ci): tag OpenAPI 'Coffres Secrets' declare + compteur de themes 15 -> 16' (#34) from fix/ci-tests-openapi-themes into main
CI / lint (push) Successful in 2m57s
CI / test (push) Successful in 5m37s
CI / build (push) Successful in 3m15s
CI / e2e (push) Successful in 18m7s
CI / security (push) Successful in 1m58s
2026-10-28 08:27:31 -04:00
bruno bf5b32de16 Merge pull request 'feat(ui): icone coffre-fort SVG pour le noeud Secrets' (#33) from fix/icone-secrets-coffre-fort into main
CI / security (push) Successful in 1m58s
CI / lint (push) Successful in 2m55s
CI / test (push) Failing after 1s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 08:25:45 -04:00
bruno 2a1780b43d docs: commentaire 15 -> 16 themes integres (meme compteur obsolete que test_themes.py)
CI / security (pull_request) Failing after 0s
CI / lint (pull_request) Successful in 3m11s
CI / test (pull_request) Failing after 5m8s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 08:25:38 -04:00
bruno be05ec99b5 fix(tests): test_theme_count 15 -> 16 (theme spark ajoute en 2.80.2) 2026-10-28 08:25:35 -04:00
bruno 3a42bc034d fix(tests): declare le tag OpenAPI 'Coffres Secrets' (test_used_tags_are_declared) 2026-10-28 08:25:32 -04:00
bruno 27e7664ef0 feat(ui): dimensionnement de l'icone coffre-fort du noeud Secrets
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Successful in 1m56s
2026-10-28 08:24:23 -04:00
bruno ccbf1d3269 feat(ui): icone coffre-fort SVG pour le noeud Secrets (remplace le cadenas emoji) 2026-10-28 08:24:16 -04:00
bruno 0ca14739f9 Merge pull request 'fix(lint): tri des imports dans backend/routers/coffres.py (I001)' (#32) from fix/ruff-coffres-imports into main
CI / security (push) Successful in 1m56s
CI / lint (push) Successful in 3m14s
CI / test (push) Failing after 5m5s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 08:16:47 -04:00
bruno d9192ef355 fix(lint): tri des imports dans backend/routers/coffres.py (I001)
CI / lint (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Successful in 1m59s
2026-10-28 08:13:35 -04:00
bruno 4c9bfd576c Merge pull request 'chore(release): version 2.81.0 (Coffre Secrets Phases 2-5)' (#31) from fix/version-2.81.0 into main
CI / security (push) Failing after 0s
CI / lint (push) Failing after 1m50s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 00:16:49 -04:00
bruno 20898cade7 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5)
CI / lint (pull_request) Failing after 0s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 00:16:25 -04:00
bruno 2c12639499 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:21 -04:00
bruno 7d2b2067ef chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:19 -04:00
bruno 0b52e6ee86 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:16 -04:00
bruno cafac69008 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:12 -04:00
bruno 871875b8e5 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:10 -04:00
bruno e5ae3e48d6 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:08 -04:00
bruno 76de3dd75e chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:05 -04:00
bruno 687427381d chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:03 -04:00
bruno e418d87802 Merge pull request '[Coffre Secrets] Phase 5 — Documentation de deploiement et mise en service' (#30) from feat/coffre-phase5-deploiement into main
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 00:15:09 -04:00
bruno a1f3690085 Merge pull request '[Coffre Secrets] Phase 2 — Migration api_keys.json, sauvegarde/restauration KEK, contrat distribution, consommateurs + correctif UI modale' (#29) from feat/coffre-phase2 into main
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 00:15:06 -04:00
bruno 0a7bed10bf Phase 5 (issue #23) : correction numerotation etape lecteurs internes (4, pas 5)
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Failing after 0s
2026-10-28 00:11:20 -04:00
bruno 32996ce9aa Phase 5 (issue #23) : deploiement et mise en service du coffre Secrets 2026-10-28 00:10:51 -04:00
bruno b42265b41e Phase 2 coffre: correctif modale (max-height viewport + scroll interne)
CI / security (pull_request) Failing after 1s
CI / lint (pull_request) Failing after 1m46s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:45:44 -04:00
bruno 0fc7654f6c Phase 2 coffre: cles i18n import (en) 2026-10-27 23:45:38 -04:00
bruno effad97472 Phase 2 coffre: cles i18n import (fr) 2026-10-27 23:45:33 -04:00
bruno 53d98bfffe Phase 2 coffre: variables OBSIGATE_SECRETS_ENABLED/KEK documentees (sans valeur) 2026-10-27 23:45:20 -04:00
bruno 62aa6079be Phases 3-4 coffre: contrat de distribution Spark et consommateurs 2026-10-27 23:45:16 -04:00
bruno 7d48f65ff9 Phase 2 coffre: documentation sauvegarde/restauration KEK 2026-10-27 23:45:13 -04:00
bruno 3acadf6ad7 Phase 2 coffre: bouton Importer l'existant (import puis purge confirmee) 2026-10-27 23:45:10 -04:00
bruno 5492f99f06 Phase 2 coffre: tests migration, restauration vierge, Spark, consommateurs 2026-10-27 23:45:05 -04:00
bruno 71221e055b Phase 2 coffre: migration, consommateur+octrois, contexte Spark imbrique 2026-10-27 23:44:58 -04:00
bruno 86330e8f65 Phase 2 coffre: module migration api_keys.json -> coffre d'instance (import + purge confirmee) 2026-10-27 23:44:43 -04:00
bruno f3b507b8d9 Merge pull request '[Theme] Nouveau theme Spark (dark/light, identique a la Console Spark) et defaut = Spark dark' (#28) from feat/theme-spark into main
CI / security (push) Failing after 0s
CI / lint (push) Successful in 3m6s
CI / test (push) Failing after 0s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-27 23:25:10 -04:00
bruno c01c7e862d Merge pull request '[fix] Mypy Coffre Secrets (3 erreurs) + version 2.80.2' (#27) from fix/mypy-coffre-2.80.2 into main
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-27 23:24:44 -04:00
bruno 99cd765997 [fix] CHANGELOG 2.80.2 : bouton Nouveau secret + plancher msgpack
CI / lint (pull_request) Failing after 0s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:20:41 -04:00
bruno 5266506b56 [fix] Securite : plancher msgpack>=1.2.3 (CVE-2026-114613, pip-audit bloquant)
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Successful in 1m56s
2026-10-27 23:20:35 -04:00
bruno 682a5030d3 [fix] Coffre Secrets : valeurField attache l'oeil a wrap avant insertBefore (NotFoundError bouton Nouveau secret)
CI / security (pull_request) Failing after 0s
CI / lint (pull_request) Successful in 3m19s
CI / test (pull_request) Failing after 1m47s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:20:30 -04:00
bruno fc3d847d85 [fix] mypy Coffre + bump 2.80.2 : CHANGELOG.md
CI / security (pull_request) Failing after 2m29s
CI / lint (pull_request) Successful in 2m55s
CI / test (pull_request) Failing after 5m10s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:10:51 -04:00
bruno 82756030c6 [fix] mypy Coffre + bump 2.80.2 : docs/ROADMAP.md 2026-10-27 23:10:48 -04:00
bruno 0352451ead [fix] mypy Coffre + bump 2.80.2 : README.fr.md 2026-10-27 23:10:44 -04:00
bruno 593cf492c3 [fix] mypy Coffre + bump 2.80.2 : README.md 2026-10-27 23:10:41 -04:00
bruno 0b8ad8b0f3 [fix] mypy Coffre + bump 2.80.2 : desktop/Cargo.lock 2026-10-27 23:10:39 -04:00
bruno 111d927ece [fix] mypy Coffre + bump 2.80.2 : desktop/Cargo.toml 2026-10-27 23:10:36 -04:00
bruno 2dae023ee3 [fix] mypy Coffre + bump 2.80.2 : desktop/tauri.conf.json 2026-10-27 23:10:34 -04:00
bruno b12122f78a [fix] mypy Coffre + bump 2.80.2 : package.json 2026-10-27 23:10:31 -04:00
bruno 0c7b3162c8 [fix] mypy Coffre + bump 2.80.2 : VERSION 2026-10-27 23:10:29 -04:00
bruno d4d050297b [fix] mypy Coffre + bump 2.80.2 : backend/coffre_store.py 2026-10-27 23:10:27 -04:00
bruno 8d768bf9d5 [fix] mypy Coffre + bump 2.80.2 : backend/coffre_crypto.py 2026-10-27 23:10:24 -04:00
bruno 47ac8bf6c0 Merge pull request '[fix] Version 2.80.1 synchronisee + corrections ruff (60 erreurs CI)' (#26) from fix/version-2.80.1-ruff into main
CI / security (push) Failing after 1s
CI / lint (push) Failing after 2m10s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-27 23:00:13 -04:00
bruno 01572736c0 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/webhooks.py
CI / security (pull_request) Successful in 1m57s
CI / lint (pull_request) Failing after 2m7s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 22:58:14 -04:00
bruno b6e5f01748 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/watcher.py 2026-10-27 22:58:11 -04:00
bruno 90da0ac990 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/tools/audit.py 2026-10-27 22:58:08 -04:00
bruno bc26d1506b [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/share.py 2026-10-27 22:58:05 -04:00
bruno 769e3ca559 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/services/vaults.py 2026-10-27 22:58:02 -04:00
bruno b5d789f02a [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/services/recent.py 2026-10-27 22:57:59 -04:00
bruno c8dce25e0d [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/services/backups.py 2026-10-27 22:57:56 -04:00
bruno b322e2d24a [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/scheduler.py 2026-10-27 22:57:53 -04:00
bruno 414ec69c0a [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/routers/realtime.py 2026-10-27 22:57:50 -04:00
bruno f5ca656c04 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/routers/backups.py 2026-10-27 22:57:47 -04:00
bruno e158232aba [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/push.py 2026-10-27 22:57:44 -04:00
bruno 632871ac24 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/notify.py 2026-10-27 22:57:40 -04:00
bruno 7af7b2c8c2 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/indexer.py 2026-10-27 22:57:36 -04:00
bruno 97132770e4 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/guide_export.py 2026-10-27 22:57:32 -04:00
bruno 683ad3ef94 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/export.py 2026-10-27 22:57:29 -04:00
bruno 604383f51c [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/coffre_store.py 2026-10-27 22:57:25 -04:00
bruno bdeb2f00b3 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/coffre_crypto.py 2026-10-27 22:57:21 -04:00
bruno 8688875fae [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/bookslm_routes.py 2026-10-27 22:57:17 -04:00
bruno a42271c7de [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/auth/user_store.py 2026-10-27 22:57:13 -04:00
bruno cf4685eaf1 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/auth/router.py 2026-10-27 22:57:10 -04:00
bruno 5023fb6774 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/audit.py 2026-10-27 22:57:06 -04:00
bruno 04eb70cbe4 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/admin.py 2026-10-27 22:57:01 -04:00
bruno 4dc010f54d [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : CHANGELOG.md 2026-10-27 22:56:57 -04:00
bruno f7e36bc44e [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : docs/ROADMAP.md 2026-10-27 22:56:53 -04:00
bruno a9ac1c06a7 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : README.fr.md 2026-10-27 22:56:48 -04:00
bruno 9bf6e7492f [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : README.md 2026-10-27 22:56:43 -04:00
bruno 14070cef43 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : desktop/Cargo.lock 2026-10-27 22:56:37 -04:00
bruno 6b1abbb50e [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : desktop/Cargo.toml 2026-10-27 22:56:33 -04:00
bruno 46c641ebc5 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : desktop/tauri.conf.json 2026-10-27 22:56:30 -04:00
bruno 2d036f5126 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : package.json 2026-10-27 22:56:27 -04:00
bruno 2c69e357b8 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : VERSION 2026-10-27 22:56:25 -04:00
bruno ba81f22d40 Merge pull request '[Coffre Secrets] Phase 1 complète : chiffrement, API et UI (1a + 1b + 1c)' (#25) from feat/coffre-secrets-phase1 into main
CI / lint (push) Failing after 1m47s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 1m58s
2026-10-27 22:29:09 -04:00
bruno 070add4e7b [Thème Spark] Anti-FOUC admin aligné sur fond/texte Spark
CI / security (pull_request) Failing after 0s
CI / lint (pull_request) Successful in 3m10s
CI / test (pull_request) Failing after 5m22s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 22:10:14 -04:00
bruno 847b3225b2 [Thème Spark] Splash et meta theme-color alignés sur Spark dark 2026-10-27 22:10:11 -04:00
bruno 3ae5c7efac [Thème Spark] Bloc :root de secours aligné sur la palette dark Spark 2026-10-27 22:10:07 -04:00
bruno 4966c27548 [Thème Spark] Ajout du thème 'spark' (dark/light, 39 variables) et défaut spark/dark 2026-10-27 22:10:04 -04:00
bruno 06c12d0383 [Coffre Secrets] Phase 1c UI : cles i18n coffres.* (en)
CI / security (pull_request) Failing after 2s
CI / lint (pull_request) Failing after 1m55s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 22:08:23 -04:00
bruno 69f3968d4a [Coffre Secrets] Phase 1c UI : cles i18n coffres.* (fr) 2026-10-27 22:08:20 -04:00
bruno d5ae66466a [Coffre Secrets] Phase 1c UI : styles vue Secrets et menu contextuel 2026-10-27 22:08:16 -04:00
bruno 2dc8be3127 [Coffre Secrets] Phase 1c UI : noeud virtuel Secrets sous Home-user dans sidebar.js (patron Partage #196) 2026-10-27 22:08:05 -04:00
bruno 040e80a36d [Coffre Secrets] Phase 1c UI : nouveau module frontend/js/coffres.js (vue Secrets, modales, copie manuelle) 2026-10-27 22:08:00 -04:00
bruno bd6b79fb41 [Coffre Secrets] Phase 1a/1b : tests crypto, store, autorisations, distribution 2026-10-27 21:45:58 -04:00
bruno 581a31161c [Coffre Secrets] Phase 1a : dependance cryptography pour AES-GCM 2026-10-27 21:45:56 -04:00
bruno 5f8f93ba6c [Coffre Secrets] Phase 1b : montage du routeur coffres a cote de config_router 2026-10-27 21:45:53 -04:00
bruno 40b3e9c5dd [Coffre Secrets] Phase 1b : routeur /api/coffres, autorisations, octrois, distribution 2026-10-27 21:45:40 -04:00
bruno 2ffc219f47 [Coffre Secrets] Phase 1b : log_coffre_event dans audit.log (sans valeur) 2026-10-27 21:45:38 -04:00
bruno 02394d3278 [Coffre Secrets] Phase 1a : store coffres/valeurs/octrois/consommateurs (0600, atomique, RLock) 2026-10-27 21:45:35 -04:00
bruno ff1ccca3cd [Coffre Secrets] Phase 1a : chiffrement par enveloppe AES-256-GCM (KEK/DEK) 2026-10-27 21:45:32 -04:00
Spark fddbaec82e feat: panneau Spark — version du service d'agents visible en mode sidebar #225
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-26 16:56:28 +00:00
Spark 8e073438a3 fix: URLs sœurs dérivées de l'environnement de l'agent — le menu inter-apps ne pointe plus la prod depuis la dev BUG-120
CI / security (push) Successful in 1m55s
CI / lint (push) Successful in 3m5s
CI / test (push) Successful in 5m23s
CI / build (push) Successful in 1m46s
CI / e2e (push) Successful in 18m9s
2026-10-26 00:06:16 +00:00
Spark 6972e9ec87 fix: planchers de sécurité pip-audit — brotli, filelock, opentelemetry-api, pypdf #224
CI / security (push) Successful in 2m42s
CI / lint (push) Successful in 3m22s
CI / test (push) Successful in 5m34s
CI / build (push) Successful in 2m48s
CI / e2e (push) Successful in 19m15s
2026-10-25 21:02:57 +00:00
Spark f29a22886a feat: URLs publiques de l'écosystème par configuration — dev/prod sans noms en dur #223
CI / lint (push) Successful in 3m11s
CI / security (push) Failing after 1m55s
CI / test (push) Successful in 5m23s
CI / build (push) Successful in 2m35s
CI / e2e (push) Successful in 18m24s
2026-10-25 18:10:59 +00:00
Spark 287afa1537 fix: CSP — frame-src autorise la console du service d'agents (mode console du panneau Spark) BUG-119
CI / lint (push) Successful in 3m5s
CI / security (push) Failing after 1m49s
CI / test (push) Successful in 5m19s
CI / build (push) Successful in 1m36s
CI / e2e (push) Successful in 18m9s
2026-10-24 03:10:34 +00:00
Spark b0ac6dfdb9 docs: #222 livré (2.77.0) — statut Roadmap
CI / lint (push) Successful in 2m58s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 5m10s
CI / build (push) Successful in 1m39s
CI / e2e (push) Successful in 18m2s
2026-10-23 04:13:51 +00:00
Spark 7f06606300 feat: panneau Spark V3 — la console en surimpression pleine page #222 2026-10-23 04:13:37 +00:00
Spark 985af3a294 docs: #221 livré (2.76.1) — statut Roadmap
CI / lint (push) Successful in 2m54s
CI / security (push) Failing after 1m40s
CI / test (push) Successful in 4m59s
CI / build (push) Failing after 11m14s
CI / e2e (push) Skipped
2026-10-22 21:21:27 +00:00
Spark 440cfdcc31 fix: bouton « Console ↗ » dans le panneau Spark #221
CI / lint (push) Successful in 2m53s
CI / security (push) Failing after 1m39s
CI / test (push) Successful in 5m3s
CI / build (push) Successful in 1m39s
CI / e2e (push) Successful in 18m1s
2026-10-22 21:20:51 +00:00
Spark 0ca92c3723 docs: #220 livré (2.76.0) — statut Roadmap
CI / lint (push) Successful in 2m52s
CI / security (push) Failing after 1m38s
CI / test (push) Successful in 5m3s
CI / build (push) Successful in 1m29s
CI / e2e (push) Successful in 17m47s
2026-10-22 19:59:24 +00:00
Spark f434f30286 feat: émission d'événements vers le journal du service d'agents #220
CI / lint (push) Successful in 2m53s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 4m57s
CI / build (push) Successful in 1m36s
CI / e2e (push) Successful in 17m40s
2026-10-22 19:58:25 +00:00
Spark f4b8935189 docs: #219 livré (2.75.0) — statut Roadmap
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-10-22 12:07:49 -04:00
Spark 014df322e7 feat: panneau Spark v2 — Markdown, largeur, thème, discussions #219
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-10-22 12:06:12 -04:00
bruno 39f9cc16e4 fix(deploy): retirer DIR_1 Bruno — doublon avec le vault par défaut home-user (#194), surconsommation mémoire
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-10-22 10:10:18 -04:00
Spark b4b3032456 docs: #218 livré (2.74.0) — statut Roadmap
CI / lint (push) Successful in 2m49s
CI / security (push) Failing after 1m40s
CI / test (push) Successful in 5m4s
CI / build (push) Successful in 1m29s
CI / e2e (push) Successful in 18m6s
2026-10-22 03:43:24 +00:00
Spark edef1ea708 feat: panneau Spark intégré à la barre commune (Fusion) #218 2026-10-22 03:43:17 +00:00
Spark 7523670949 fix: barre commune v0.1.4, palier mobile élargi à 900 px (suite BUG-118, #215)
CI / lint (push) Successful in 2m54s
CI / security (push) Failing after 1m34s
CI / test (push) Successful in 4m54s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 18m28s
2026-10-21 12:47:16 -04:00
Spark d81f328859 feat: menu des applications par la marque ✦ de la barre commune (commons v0.1.3, #215)
CI / lint (push) Successful in 2m47s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m55s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 18m20s
2026-10-21 12:27:12 -04:00
Spark 1a39d601de fix: barre d'apps mobile — CSS commune v0.1.2 et rail contextuel (BUG-118, #215)
CI / lint (push) Successful in 2m51s
CI / security (push) Successful in 1m33s
CI / test (push) Successful in 4m58s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 18m3s
2026-10-21 11:36:35 -04:00
Spark 68d3dd85a9 feat: validation locale du jeton draco_session — repli SSO dans get_current_user (#217)
CI / lint (push) Successful in 3m34s
CI / security (push) Successful in 2m17s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 2m15s
CI / e2e (push) Successful in 18m31s
2026-10-20 23:47:45 -04:00
Spark cb80b08f77 feat: émettrice SSO draco_session — cookie partagé .dracodev.net, renouvellement et révocation (#216)
CI / lint (push) Successful in 3m32s
CI / security (push) Successful in 2m18s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 3m52s
CI / e2e (push) Successful in 19m0s
2026-10-20 23:33:52 -04:00
Spark e5577b9996 fix: planchers de sécurité des dépendances — pip-audit vierge (aiohttp, mcp, pillow, certifi)
CI / lint (push) Successful in 3m40s
CI / security (push) Successful in 2m23s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 3m16s
CI / e2e (push) Successful in 21m7s
2026-10-20 23:10:34 -04:00
Spark 9bc31d069b feat: barre d'apps commune et références croisées des notes (#215)
CI / lint (push) Successful in 3m32s
CI / security (push) Failing after 2m27s
CI / test (push) Successful in 5m36s
CI / build (push) Successful in 2m12s
CI / e2e (push) Successful in 18m18s
2026-10-20 22:20:48 -04:00
bruno 411667fd88 feat: menu contextuel Déplacer/Dupliquer sidebar + réparation drag & drop interne #201 #BUG-117
CI / lint (push) Successful in 3m13s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 5m16s
CI / build (push) Successful in 1m52s
CI / e2e (push) Successful in 18m2s
2026-10-15 10:02:48 -04:00
bruno ed9029d760 fix: éditeur Forge — textarea qui ne grandit pas après insertion programmatique (tableau/code) #BUG-116
CI / lint (push) Successful in 3m17s
CI / security (push) Successful in 2m0s
CI / test (push) Successful in 5m14s
CI / build (push) Successful in 1m49s
CI / e2e (push) Successful in 17m57s
2026-10-14 22:09:28 -04:00
bruno 1892062d88 fix: éditeur Forge — espace checkbox/texte aperçu, flash du preview, menus table/langue coupés #BUG-115
CI / lint (push) Successful in 3m11s
CI / security (push) Successful in 2m0s
CI / test (push) Successful in 5m16s
CI / build (push) Successful in 1m49s
CI / e2e (push) Successful in 17m47s
2026-10-14 21:56:37 -04:00
bruno cbe3d9bc75 fix: popup currentPath is not defined après suppression/renommage de répertoire #BUG-114
CI / lint (push) Successful in 3m9s
CI / security (push) Successful in 1m59s
CI / test (push) Successful in 5m12s
CI / build (push) Successful in 1m49s
CI / e2e (push) Successful in 17m57s
2026-10-14 21:20:08 -04:00
bruno 2ebfe74459 perf: suppression de répertoire incrémentale au lieu du re-scan complet #BUG-113
CI / lint (push) Successful in 3m9s
CI / security (push) Successful in 1m55s
CI / test (push) Successful in 5m25s
CI / build (push) Successful in 1m48s
CI / e2e (push) Successful in 17m56s
2026-10-14 16:56:56 -04:00
100 changed files with 13003 additions and 194 deletions
+38
View File
@@ -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.
+7 -1
View File
@@ -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
+444 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.68.2**.
> [Unreleased](#unreleased). La dernière version livrée est **2.81.3**.
---
@@ -14,6 +14,449 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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é
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.68.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.81.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.68.2).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.1).
---
*Projet : ObsiGate | Version : 2.68.2 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.81.1 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.68.2-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.81.1-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.68.2).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.1).
---
*Project: ObsiGate | Version: 2.68.2 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.81.1 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.68.2
2.81.3
+2 -2
View File
@@ -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(),
}
+184
View File
@@ -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
View File
@@ -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.
+20 -6
View File
@@ -9,6 +9,7 @@ import sys
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import draco_session
from backend.services.net import get_client_ip
from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token
@@ -86,20 +87,22 @@ def get_current_user(
token = request.cookies["access_token"]
if not token:
return None
return _draco_fallback(request)
payload = decode_token(token)
if not payload or payload.get("type") != "access":
return None
return _draco_fallback(request)
# BUG-027: access tokens revoked at logout must be rejected immediately.
# (Le repli draco reste sûr : la déconnexion globale révoque aussi
# le jti draco et efface son cookie.)
jti = payload.get("jti")
if jti and is_token_revoked(jti):
return None
return _draco_fallback(request)
user = get_user(payload["sub"])
if not user or not user.get("active"):
return None
return _draco_fallback(request)
# BUG-028: a password change invalidates every token issued before it.
pca = user.get("password_changed_at")
@@ -107,9 +110,9 @@ def get_current_user(
if pca is not None and iat is not None:
try:
if int(iat) < int(float(pca)):
return None
return _draco_fallback(request)
except (TypeError, ValueError):
return None
return _draco_fallback(request)
# Attach vault permissions from the token (snapshot at login time)
user["_token_vaults"] = payload.get("vaults", [])
@@ -127,6 +130,17 @@ def get_current_user(
return user
def _draco_fallback(request: Request) -> dict | None:
"""Fusion ACC-3.2 (#9) : à défaut de jeton natif valide, le cookie
partagé draco_session authentifie l'utilisateur (sub = id
ObsiGate, correspondance directe — voir draco_session.user_for_draco).
"""
user = draco_session.user_for_draco(request)
if user is not None:
user["_request_ip"] = get_client_ip(request)
return user
def require_auth(current_user=Depends(get_current_user)):
"""Dependency: require a valid authenticated user."""
if not current_user:
+22 -2
View File
@@ -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)
+7 -7
View File
@@ -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
+1 -1
View File
@@ -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
+215
View File
@@ -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)
+306
View File
@@ -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
+860
View File
@@ -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")}
+197
View File
@@ -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}
+307
View File
@@ -0,0 +1,307 @@
# backend/draco_session.py
# Fusion ACC-3.1 (#216) — Émettrice SSO « draco_session » (option 2a, C2).
#
# ObsiGate est l'unique émettrice du cookie de session partagé
# ``draco_session`` (Domain=.dracodev.net) que les autres applications
# de l'écosystème (Flowdeck, TikTak, Kanbante) valideront localement
# (ACC-3.2, leurs propres tickets). Design : architecture Fusion §5.2 —
# JWS compact HS256, claims minimaux (sub, name, iat, exp 12 h, jti),
# aucun rôle dans le jeton, renouvellement glissant par l'émettrice,
# révocation par jti exposée aux autres apps.
#
# Configuration (environnement) :
# DRACO_SESSION_SECRET secret de signature partagé (obligatoire
# pour activer la fonction ; sans lui,
# aucun cookie n'est émis et rien ne change)
# DRACO_SESSION_SECRET_PREV ancien secret accepté en vérification
# pendant la fenêtre de rotation (24 h)
# DRACO_SESSION_COOKIE_DOMAIN domaine du cookie (défaut .dracodev.net ;
# vide = cookie lié à l'hôte, utile en dev)
# DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la
# liste de révocation (GET /api/sso/revocations)
from __future__ import annotations
import json
import logging
import os
import threading
import time
import uuid
from pathlib import Path
from urllib.parse import urlparse
from jose import JWTError, jwt
logger = logging.getLogger("obsigate.draco_session")
COOKIE_NAME = "draco_session"
ALGORITHM = "HS256"
TOKEN_TTL_SECONDS = 12 * 3600 # durée de vie du jeton (design §5.2)
RENEWAL_WINDOW_SECONDS = 2 * 3600 # réémettre si exp - now < 2 h
REVOKED_FILE = Path("data/draco_revoked.json")
ALLOWED_RETURN_SUFFIX = "dracodev.net"
_lock = threading.Lock()
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
def _secret() -> str:
return os.environ.get("DRACO_SESSION_SECRET", "")
def _secret_prev() -> str:
return os.environ.get("DRACO_SESSION_SECRET_PREV", "")
def _cookie_domain() -> str:
return os.environ.get("DRACO_SESSION_COOKIE_DOMAIN", ".dracodev.net")
def enabled() -> bool:
"""La fonction est active seulement si le secret partagé est configuré."""
return bool(_secret())
# ── Révocation (jti) ────────────────────────────────────────────────────
def _load_revoked() -> dict[str, int]:
global _revoked
if _revoked is None:
try:
data = json.loads(REVOKED_FILE.read_text(encoding="utf-8"))
_revoked = {str(k): int(v) for k, v in data.items()} \
if isinstance(data, dict) else {}
except (OSError, ValueError):
_revoked = {}
return _revoked
def _save_revoked() -> None:
REVOKED_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = REVOKED_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(_revoked or {}), encoding="utf-8")
tmp.replace(REVOKED_FILE)
def revoke(jti: str, exp: int) -> None:
"""Révoque un jeton par son jti (déconnexion globale)."""
with _lock:
store = _load_revoked()
store[jti] = int(exp)
now = int(time.time())
for k in [k for k, v in store.items() if v <= now]:
del store[k] # purge des expirés
_save_revoked()
def is_revoked(jti: str) -> bool:
with _lock:
return jti in _load_revoked()
def revoked_list() -> list[dict]:
"""Liste des révocations actives (pour les autres apps, cache 60 s)."""
with _lock:
now = int(time.time())
return [{"jti": jti, "exp": exp}
for jti, exp in _load_revoked().items() if exp > now]
# ── Émission / vérification ─────────────────────────────────────────────
def issue_token(user: dict) -> tuple[str, str, int]:
"""Émet un JWS pour l'utilisateur. Retourne (jeton, jti, exp)."""
now = int(time.time())
exp = now + TOKEN_TTL_SECONDS
jti = uuid.uuid4().hex
claims = {
"sub": str(user.get("id") or user.get("username") or ""),
"name": str(user.get("display_name") or user.get("username") or ""),
"iat": now,
"exp": exp,
"jti": jti,
}
return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp
def decode_claims(token: str) -> dict | None:
"""Vérifie signature et expiration (pas la révocation)."""
if not token or not enabled():
return None
for secret in (_secret(), _secret_prev()):
if not secret:
continue
try:
return jwt.decode(token, secret, algorithms=[ALGORITHM])
except JWTError:
continue
return None
def verify_token(token: str) -> dict | None:
"""decode_claims + contrôle de révocation du jti."""
claims = decode_claims(token)
if claims is None:
return None
if claims.get("jti") and is_revoked(str(claims["jti"])):
return None
return claims
def needs_renewal(claims: dict) -> bool:
"""Vrai si le jeton entre dans la fenêtre de renouvellement (< 2 h)."""
try:
return int(claims["exp"]) - int(time.time()) < RENEWAL_WINDOW_SECONDS
except (KeyError, TypeError, ValueError):
return True
def set_cookie(response, user: dict, secure: bool) -> None:
"""Pose le cookie draco_session sur une réponse (émission initiale)."""
if not enabled():
return
token, _jti, _exp = issue_token(user)
domain = _cookie_domain()
extra = {"domain": domain} if domain else {}
response.set_cookie(COOKIE_NAME, token, max_age=TOKEN_TTL_SECONDS,
httponly=True, samesite="lax", secure=secure,
path="/", **extra)
def clear_cookie(response) -> None:
domain = _cookie_domain()
extra = {"domain": domain} if domain else {}
response.delete_cookie(COOKIE_NAME, path="/", **extra)
# ── Validation locale (ACC-3.2, #9) ─────────────────────────────────────
def user_for_draco(request) -> dict | None:
"""Utilisateur ObsiGate prouvé par le cookie draco_session.
ObsiGate est à la fois émettrice et consommatrice : le `sub` du
jeton EST son identifiant d'utilisateur — la correspondance est
directe, par id. Le garde-fou « jeton antérieur au dernier
changement de mot de passe » (BUG-028 côté natif) est appliqué
à l'identique via le claim `iat`.
"""
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
if not claims:
return None
sub = str(claims.get("sub", ""))
if not sub:
return None
from backend.auth.user_store import get_all_users
for candidate in get_all_users():
if str(candidate.get("id")) != sub:
continue
if not candidate.get("active"):
return None
pca = candidate.get("password_changed_at")
iat = claims.get("iat")
if pca is not None and iat is not None:
try:
if int(iat) < int(float(pca)):
return None
except (TypeError, ValueError):
return None
user = dict(candidate)
user["_token_vaults"] = candidate.get("vaults", [])
user["_token_scope"] = "full"
user["_draco_sub"] = sub
return user
return None
# ── Renouvellement glissant (middleware) ────────────────────────────────
class DracoRenewalMiddleware:
"""Réémet le cookie quand l'utilisateur est actif chez l'émettrice.
Middleware ASGI pur (pas de BaseHTTPMiddleware : la pile d'ObsiGate
streame des réponses SSE/WS que BaseHTTPMiddleware mettrait en
tampon). On enveloppe l'envoi de la réponse : si la requête porte
un cookie d'accès natif valide et que le cookie draco est absent,
invalide ou dans sa fenêtre de renouvellement (< 2 h), on ajoute
un Set-Cookie frais aux en-têtes de la réponse. La révocation
ciblée d'un jti draco est ainsi « guérie » par la prochaine
activité authentifiée locale — la déconnexion globale, elle,
révoque aussi les jetons natifs, donc plus rien n'est réémis.
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope.get("type") != "http" or not enabled():
await self.app(scope, receive, send)
return
cookie_header = ""
for name, value in scope.get("headers") or []:
if name == b"cookie":
cookie_header = value.decode("latin-1")
break
cookies = {}
for part in cookie_header.split(";"):
if "=" in part:
k, v = part.strip().split("=", 1)
cookies[k] = v
user = self._user_from_native(cookies.get("access_token", ""))
if user is None:
await self.app(scope, receive, send)
return
claims = verify_token(cookies.get(COOKIE_NAME, "")) \
if cookies.get(COOKIE_NAME) else None
if claims is not None and not needs_renewal(claims):
await self.app(scope, receive, send)
return
token, _jti, _exp = issue_token(user)
domain = _cookie_domain()
cookie = (f"{COOKIE_NAME}={token}; Max-Age={TOKEN_TTL_SECONDS}; "
f"Path=/; HttpOnly; SameSite=lax")
if domain:
cookie += f"; Domain={domain}"
if scope.get("scheme") == "https":
cookie += "; Secure"
async def send_with_cookie(message):
if message.get("type") == "http.response.start":
headers = list(message.get("headers") or [])
headers.append((b"set-cookie", cookie.encode("latin-1")))
message = {**message, "headers": headers}
await send(message)
await self.app(scope, receive, send_with_cookie)
@staticmethod
def _user_from_native(access_token: str):
"""Utilisateur local prouvé par le cookie d'accès natif, ou None."""
if not access_token:
return None
try:
from backend.auth.jwt_handler import decode_token
from backend.auth.user_store import get_user
payload = decode_token(access_token)
if not payload or payload.get("type") != "access":
return None
return get_user(str(payload.get("sub", "")))
except Exception: # le renouvellement ne casse jamais une requête
return None
# ── return_to (anti open-redirect) ──────────────────────────────────────
def validate_return_to(url: str) -> str | None:
"""Accepte uniquement https vers dracodev.net ou un sous-domaine."""
if not url:
return None
parsed = urlparse(url)
host = (parsed.hostname or "").lower()
if parsed.scheme != "https" or not host:
return None
if host == ALLOWED_RETURN_SUFFIX or host.endswith("." + ALLOWED_RETURN_SUFFIX):
return url
return None
+2 -2
View File
@@ -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"/>
+238
View File
@@ -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)
+1 -1
View File
@@ -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)
+50 -4
View File
@@ -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] = []
@@ -1312,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.
+127 -3
View File
@@ -186,15 +186,21 @@ 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
agent = agent_origine()
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}; "
"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'; "
@@ -493,6 +499,12 @@ from backend.auth.scope import TokenScopeMiddleware
app.add_middleware(TokenScopeMiddleware)
# Fusion ACC-3.1 (#216) : renouvellement glissant du cookie SSO
# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas défini).
from backend.draco_session import DracoRenewalMiddleware
app.add_middleware(DracoRenewalMiddleware)
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
# donc toute lecture cross-origin est refusée (défense explicite, plus
@@ -509,6 +521,16 @@ 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
@@ -518,10 +540,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
@@ -533,6 +557,7 @@ from backend.routers.realtime import router as realtime_router
from backend.routers.scheduler import router as scheduler_router
from backend.routers.search import router as search_router
from backend.routers.sharing import router as sharing_router
from backend.routers.sso import router as sso_router
from backend.routers.vaults import router as vaults_router
from backend.routers.webhooks import router as webhooks_router
from backend.skills_routes import router as skills_router
@@ -543,11 +568,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
@@ -819,6 +847,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")
@@ -892,10 +950,76 @@ if FRONTEND_DIR.exists():
return HTMLResponse(content=_html_with_nonce(request, "admin.html"), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Admin page not found")
@app.get("/recherche", response_class=HTMLResponse)
async def serve_recherche_page(request: Request):
"""Page « Rechercher partout » (fusion #68) — déclarée AVANT le
catch-all SPA comme /admin.html, sinon index.html serait servi.
frontend/recherche.html est servi avec les jetons {{URL_*}}
remplacés par urls_publiques() (même contrat que le fragment
de barre) et le nonce CSP frais ; la réponse pose sa propre
CSP (préservée par SecurityHeadersMiddleware, qui ne la pose
que si elle est absente) dont le connect-src autorise les
origines sœurs — sinon le navigateur bloquerait les appels
sœurs avant même CORS. La CSP par défaut (same-origin) reste
inchangée pour toutes les autres pages.
"""
import json as _json
from backend.csp import inject_csp_nonce
from backend.draco_config import urls_publiques
from backend.recherche_cors import origin_of
recherche_file = FRONTEND_DIR / "recherche.html"
if not recherche_file.exists():
raise HTTPException(status_code=404, detail="Recherche page not found")
html = recherche_file.read_text(encoding="utf-8")
urls = urls_publiques()
for jeton, cle in (
("{{URL_OBSIGATE}}", "obsigate"),
("{{URL_FLOWDECK}}", "flowdeck"),
("{{URL_TIKTAK}}", "tiktak"),
("{{URL_KANBANTE}}", "kanbante"),
):
html = html.replace(jeton, urls.get(cle, ""))
script = (
"<script>window.DRACO_URLS = "
+ _json.dumps(urls).replace("<", "\\u003c")
+ ";</script>"
)
if "</head>" in html:
html = html.replace("</head>", script + "</head>", 1)
else:
html = script + html
html = inject_csp_nonce(html, request.state.csp_nonce)
soeurs = " ".join(sorted({
origin_of(urls.get(cle, "")) or ""
for cle in ("obsigate", "flowdeck", "tiktak", "kanbante")
} - {""}))
csp = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{request.state.csp_nonce}'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
f"connect-src 'self' {soeurs}; "
"font-src 'self' data:; "
"worker-src 'self' blob:; "
"frame-src 'self'; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'self';"
)
return HTMLResponse(
content=html,
headers={"Cache-Control": "no-cache", "Content-Security-Policy": csp},
)
@app.get("/{full_path:path}")
async def serve_spa(request: Request, full_path: str):
"""Serve the SPA index.html for all non-API routes."""
index_file = FRONTEND_DIR / "index.html"
if index_file.exists():
return HTMLResponse(content=_html_with_nonce(request, "index.html"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_index_html(request), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Frontend not found")
+4 -4
View File
@@ -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)
+5
View File
@@ -28,6 +28,8 @@ TAGS_METADATA: list[dict[str, str]] = [
{"name": "Vaults", "description": "List, add and remove vaults; per-vault display settings; attachment indexing."},
{"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."},
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
{"name": "ExternalRefs", "description": "Cross-application references from a note to Flowdeck, TikTak, Kanbante or ObsiGate objects (Fusion program, ACC-1.3)."},
{"name": "SSO", "description": "draco_session single sign-on emitter: authorize entry point and jti revocation list for the other dracodev apps (Fusion program, ACC-3.1)."},
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
{"name": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."},
@@ -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"),
+2 -2
View File
@@ -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)
+189
View File
@@ -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
View File
@@ -5,7 +5,10 @@ python-frontmatter==1.1.0
mistune==3.3.3
python-multipart==0.0.31
aiofiles==23.2.1
aiohttp>=3.9.0
# Plancher de sécurité : 3.14.3 est vulnérable à 8 CVE (dont
# CVE-2026-111063/111064 et GHSA-2g87-pp6c-29x5), toutes corrigées en
# 3.14.4 — pip-audit (CI, bloquant) exige de rester au-dessus.
aiohttp>=3.14.4
watchdog>=4.0.0
argon2-cffi>=23.1.0
python-jose>=3.3.0
@@ -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
+2 -2
View File
@@ -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),
})
+731
View File
@@ -0,0 +1,731 @@
"""API du coffre Secrets ObsiGate (Phase 1b, spec §7, §10, §11).
Monté dans ``backend/main.py`` à côté de ``config_router`` — JAMAIS dans
``backend/routers/vaults.py`` : un coffre n'est pas un vault de notes, il
ne passe ni par l'indexeur, ni par la recherche, ni par le graphe, et
aucun outil MCP de secret n'existe.
Règles d'or, appliquées côté serveur à chaque requête :
- Toutes les lectures renvoient des métadonnées seules (``has_value``,
version, dates, octrois actifs) : jamais de valeur, jamais de texte
chiffré, jamais de nonce.
- Seul ``POST /api/coffres/distribuer`` rend une valeur, et uniquement à
un consommateur authentifié par jeton (hash + jti, jamais la session
utilisateur), avec un octroi actif et un contexte obligatoire.
- Autorisations (spec §6) : le propriétaire seul sur son coffre
personnel ; l'admin gère les coffres d'instance, les consommateurs et
la KEK, jamais le coffre personnel d'autrui ; l'ACL « * » des vaults
ne donne AUCUN droit sur un coffre (``check_vault_access`` n'est pas
utilisé ici, volontairement).
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
qu'il n'est pas activé, toutes les routes répondent 503.
- Phase 2 (issues #20) : migration ``api_keys.json`` -> coffre
d'instance, en deux temps (import, puis purge confirmée), et
enregistrement d'un consommateur avec ses octrois en un appel.
- Phase 3 (issue #21) : ``/distribuer`` accepte le contexte Spark à
plat ou imbriqué (``contexte: {type: "mission", id}``) ; les codes
d'erreur restent ceux de la Phase 1b (voir
``docs/COFFRE-contrat-distribution.md`` pour la correspondance avec
l'état ``acces_requis`` côté Spark).
"""
from __future__ import annotations
import logging
import os
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from backend.audit import get_recent_entries, log_coffre_event
from backend.auth.middleware import require_admin, require_auth
from backend.coffre_crypto import (
CryptoIndisponibleError,
KEKAbsenteError,
generate_kek_b64,
)
from backend.coffre_migration import (
COFFRE_INTERNE_SLUG,
creer_consommateur_avec_octrois,
importer_api_keys,
lister_candidats,
purger_api_keys,
)
from backend.coffre_store import (
CoffreExisteError,
IntrouvableError,
SecretExisteError,
SecretExpireError,
ValidationError,
compter_octrois_actifs,
compter_secrets,
create_coffre,
create_consommateur,
create_octroi,
create_secret,
delete_secret,
distribuer_valeur,
get_coffre,
get_coffre_by_id,
get_coffre_or_404,
get_octroi,
get_secret,
list_coffres,
list_consommateurs,
list_octrois,
list_secrets,
revoquer_consommateur,
revoquer_octroi,
rotate_dek,
rotate_kek,
rotate_secret,
secret_metadata,
secrets_accessibles_utilisateur,
statut_octroi_consommateur,
toucher_utilisation,
verifier_jeton_consommateur,
)
logger = logging.getLogger("obsigate.coffres")
router = APIRouter(tags=["Coffres Secrets"])
CODES_ERREUR = {
"octroi_absent",
"octroi_revoque",
"octroi_expire",
"secret_expire",
"kek_absente",
"contexte_manquant",
"consommateur_invalide",
"consommateur_revoque",
"crypto_indisponible",
"secrets_desactives",
}
def secrets_actives() -> bool:
"""Feature flag : défaut désactivé, activation explicite par l'opérateur."""
return os.environ.get("OBSIGATE_SECRETS_ENABLED", "false").lower() in (
"1",
"true",
"yes",
"on",
)
def _exiger_actives() -> None:
if not secrets_actives():
raise HTTPException(status_code=503, detail="secrets_desactives")
def _username(user: dict) -> str:
return str(user.get("username", ""))
def _est_admin(user: dict) -> bool:
return user.get("role") == "admin"
def _peut_voir_coffre(user: dict, coffre: dict) -> bool:
"""Voir les métadonnées d'un coffre."""
if coffre["proprietaire"] == _username(user):
return True
if _est_admin(user) and coffre["perimetre"] == "instance":
return True
# Octroi utilisateur (coffre-wide ou par secret) : accès en lecture.
accessibles = secrets_accessibles_utilisateur(_username(user), coffre)
if accessibles is None:
return True
return len(accessibles) > 0
def _peut_gerer_coffre(user: dict, coffre: dict) -> bool:
"""Créer / roter / supprimer des secrets et gérer les octrois."""
if coffre["proprietaire"] == _username(user):
return True
return _est_admin(user) and coffre["perimetre"] == "instance"
def _exiger_voir(user: dict, coffre: dict) -> None:
if not _peut_voir_coffre(user, coffre):
raise HTTPException(status_code=403, detail="Accès refusé à ce coffre")
def _exiger_gerer(user: dict, coffre: dict) -> None:
if not _peut_gerer_coffre(user, coffre):
raise HTTPException(status_code=403, detail="Accès refusé à ce coffre")
def _coffre_or_404(slug: str) -> dict:
try:
return get_coffre_or_404(slug)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _secret_or_404(coffre: dict, nom: str) -> dict:
try:
secret = get_secret(coffre["id"], nom)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if secret is None:
raise HTTPException(status_code=404, detail=f"Secret introuvable : {nom}")
return secret
def _metadata_coffre(coffre: dict) -> dict:
return {
"slug": coffre["slug"],
"nom": coffre["nom"],
"description": coffre.get("description", ""),
"proprietaire": coffre["proprietaire"],
"perimetre": coffre["perimetre"],
"statut": coffre["statut"],
"kek_configuree": coffre.get("dek_chiffree") is not None,
"nb_secrets": compter_secrets(coffre["id"]),
"cree_le": coffre["cree_le"],
}
def _metadata_secret(coffre: dict, secret: dict) -> dict:
return secret_metadata(secret, compter_octrois_actifs(secret["id"]))
def _secrets_visibles(user: dict, coffre: dict) -> list:
"""Secrets visibles par l'utilisateur (métadonnées seules)."""
if coffre["proprietaire"] == _username(user) or (
_est_admin(user) and coffre["perimetre"] == "instance"
):
return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])]
accessibles = secrets_accessibles_utilisateur(_username(user), coffre)
if accessibles is None:
return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])]
return [
_metadata_secret(coffre, s)
for s in list_secrets(coffre["id"])
if s["id"] in accessibles
]
def _audit(user: str, action: str, **kwargs) -> None:
try:
log_coffre_event(user, action, **kwargs)
except Exception as exc: # l'audit ne doit jamais casser l'opération
logger.warning("Échec audit coffre (%s) : %s", action, exc)
# ---------------------------------------------------------------------------
# Coffres
# ---------------------------------------------------------------------------
@router.get("/api/coffres")
async def api_list_coffres(current_user=Depends(require_auth)):
_exiger_actives()
visibles = [c for c in list_coffres() if _peut_voir_coffre(current_user, c)]
return [_metadata_coffre(c) for c in visibles]
@router.post("/api/coffres")
async def api_create_coffre(body: dict = Body(...), current_user=Depends(require_auth)):
_exiger_actives()
slug = body.get("slug", "")
perimetre = body.get("perimetre", "personnel")
if perimetre == "instance" and not _est_admin(current_user):
raise HTTPException(
status_code=403, detail="Seul un admin peut créer un coffre d'instance"
)
try:
coffre = create_coffre(
slug=slug,
nom=body.get("nom", ""),
proprietaire=_username(current_user),
perimetre=perimetre,
description=body.get("description", ""),
)
except CoffreExisteError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"coffre_cree",
coffre=coffre["slug"],
resultat="ok",
)
return _metadata_coffre(coffre)
# ---------------------------------------------------------------------------
# Consommateurs (admin seulement, jeton rendu une seule fois)
# ---------------------------------------------------------------------------
@router.get("/api/coffres/consommateurs")
async def api_list_consommateurs(current_user=Depends(require_admin)):
_exiger_actives()
return list_consommateurs()
@router.post("/api/coffres/consommateurs")
async def api_create_consommateur(
body: dict = Body(...), current_user=Depends(require_admin)
):
_exiger_actives()
try:
consommateur, jeton = create_consommateur(
nom=body.get("nom", ""), cree_par=_username(current_user)
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"consommateur_cree",
consommateur=consommateur["nom"],
resultat="ok",
)
# Le jeton n'est rendu qu'ici, jamais stocké en clair, jamais relisté.
return {**consommateur, "jeton": jeton}
@router.post("/api/coffres/consommateurs/avec-octrois")
async def api_create_consommateur_avec_octrois(
body: dict = Body(...), current_user=Depends(require_admin)
):
"""Phase 4 : enregistrer une app = un consommateur + ses octrois.
Le coffre n'est jamais modifié par cette opération. Le jeton n'est
rendu qu'ici, jamais stocké en clair, jamais relisté. Un octroi en
erreur (coffre ou secret introuvable) est signalé dans ``erreurs``
sans annuler le consommateur ni les autres octrois.
"""
_exiger_actives()
try:
consommateur, jeton, octrois, erreurs = creer_consommateur_avec_octrois(
nom=body.get("nom", ""),
octrois_demandes=body.get("octrois") or [],
cree_par=_username(current_user),
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"consommateur_cree",
consommateur=consommateur["nom"],
resultat="ok",
)
for octroi in octrois:
coffre_octroi = get_coffre_by_id(octroi["coffre_id"])
_audit(
_username(current_user),
"octroi_cree",
coffre=coffre_octroi["slug"] if coffre_octroi else None,
resultat="ok",
)
return {**consommateur, "jeton": jeton, "octrois": octrois, "erreurs": erreurs}
@router.delete("/api/coffres/consommateurs/{consommateur_id}")
async def api_revoke_consommateur(
consommateur_id: str, current_user=Depends(require_admin)
):
_exiger_actives()
try:
consommateur = revoquer_consommateur(consommateur_id)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_audit(
_username(current_user),
"consommateur_revoque",
consommateur=consommateur["nom"],
resultat="ok",
)
return consommateur
# ---------------------------------------------------------------------------
# Migration Phase 2 (admin seulement) : api_keys.json -> coffre d'instance.
# L'import ne supprime rien ; la purge exige une confirmation explicite
# et ne supprime que les noms listés. Aucune réponse ne contient jamais
# une valeur, seulement des noms et des comptes.
# Ces routes sont déclarées AVANT /api/coffres/{slug} pour ne pas être
# capturées par le paramètre slug.
# ---------------------------------------------------------------------------
@router.get("/api/coffres/migration/api-keys")
async def api_migration_apercu(current_user=Depends(require_admin)):
_exiger_actives()
return {"coffre": COFFRE_INTERNE_SLUG, "candidats": lister_candidats()}
@router.post("/api/coffres/migration/api-keys/import")
async def api_migration_import(current_user=Depends(require_admin)):
_exiger_actives()
try:
resultat = importer_api_keys(cree_par=_username(current_user))
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
for nom in resultat["migres"]:
_audit(
_username(current_user),
"migration_api_keys",
coffre=COFFRE_INTERNE_SLUG,
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
resultat="ok",
)
return resultat
@router.post("/api/coffres/migration/api-keys/purger")
async def api_migration_purger(
body: dict = Body(...), current_user=Depends(require_admin)
):
_exiger_actives()
if body.get("confirmer") is not True:
raise HTTPException(status_code=400, detail="confirmation_requise")
try:
resultat = purger_api_keys(
noms=body.get("noms") or [], confirmer=True
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for nom in resultat["supprimes"]:
_audit(
_username(current_user),
"migration_api_keys_purge",
coffre=COFFRE_INTERNE_SLUG,
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
resultat="ok",
)
return resultat
# ---------------------------------------------------------------------------
# Distribution (serveur-à-serveur, hors session utilisateur, hors LLM)
# ---------------------------------------------------------------------------
@router.post("/api/coffres/distribuer")
async def api_distribuer(request: Request, body: dict = Body(...)):
_exiger_actives()
reference = (body.get("reference") or "").strip()
# Phase 3 (Spark) : le contexte peut arriver à plat
# (contexte_type / contexte_id) ou imbriqué (contexte: {type, id},
# forme envoyée par le client Spark). Les deux formes sont acceptées.
contexte_type = str(body.get("contexte_type") or "").strip()
contexte_id_raw = body.get("contexte_id")
contexte_id = "" if contexte_id_raw is None else str(contexte_id_raw).strip()
if not contexte_type or not contexte_id:
contexte = body.get("contexte")
if isinstance(contexte, dict):
contexte_type = contexte_type or str(contexte.get("type") or "").strip()
contexte_id_raw = contexte.get("id")
contexte_id = contexte_id or (
"" if contexte_id_raw is None else str(contexte_id_raw).strip()
)
if not contexte_type or not contexte_id:
raise HTTPException(status_code=400, detail="contexte_manquant")
if not reference.startswith("coffre:") or "/" not in reference[7:]:
raise HTTPException(status_code=400, detail="reference_invalide")
slug, nom = reference[7:].split("/", 1)
# Authentification consommateur : Bearer, hash + statut, jamais un JWT
# de session utilisateur.
auth = request.headers.get("Authorization", "")
jeton = auth[7:] if auth.startswith("Bearer ") else ""
consommateur = verifier_jeton_consommateur(jeton)
if consommateur is None:
_audit("consommateur", "distribution_refusee", secret_ref=reference,
resultat="consommateur_invalide", contexte_id=contexte_id)
raise HTTPException(status_code=401, detail="consommateur_invalide")
if consommateur["statut"] != "actif":
_audit("consommateur", "distribution_refusee", secret_ref=reference,
consommateur=consommateur["nom"],
resultat="consommateur_revoque", contexte_id=contexte_id)
raise HTTPException(status_code=403, detail="consommateur_revoque")
try:
coffre = get_coffre(slug)
secret = get_secret(coffre["id"], nom) if coffre else None
except ValidationError:
raise HTTPException(status_code=400, detail="reference_invalide") from None
if coffre is None or secret is None or coffre["statut"] != "actif":
# Anti-énumération : un secret inexistant se comporte comme un
# octroi absent, il ne révèle pas l'existence du coffre/secret.
_audit("consommateur", "distribution_refusee", secret_ref=reference,
consommateur=consommateur["nom"],
resultat="octroi_absent", contexte_id=contexte_id)
raise HTTPException(status_code=403, detail="octroi_absent")
statut, _octroi = statut_octroi_consommateur(
consommateur["id"], coffre["id"], secret["id"]
)
if statut != "ok":
code = {
"revoque": "octroi_revoque",
"expire": "octroi_expire",
"absent": "octroi_absent",
}[statut]
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat=code, contexte_id=contexte_id)
raise HTTPException(status_code=403, detail=code)
try:
valeur = distribuer_valeur(coffre, secret)
except SecretExpireError:
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat="secret_expire", contexte_id=contexte_id)
raise HTTPException(status_code=410, detail="secret_expire") from None
except KEKAbsenteError:
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat="kek_absente", contexte_id=contexte_id)
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
toucher_utilisation(secret["id"])
_audit("consommateur", "distribution_ok", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat="ok", contexte_id=contexte_id)
return {
"reference": reference,
"valeur": valeur,
"version": secret["version_courante"],
}
# ---------------------------------------------------------------------------
# Un coffre, ses secrets, ses octrois, son journal
# ---------------------------------------------------------------------------
@router.get("/api/coffres/{slug}")
async def api_get_coffre(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
return _metadata_coffre(coffre)
@router.get("/api/coffres/{slug}/secrets")
async def api_list_secrets(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
return _secrets_visibles(current_user, coffre)
@router.post("/api/coffres/{slug}/secrets")
async def api_create_secret(
slug: str, body: dict = Body(...), current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
secret = create_secret(
coffre=coffre,
nom=body.get("nom", ""),
type_secret=body.get("type", "autre"),
valeur=body.get("valeur", ""),
service=body.get("service", ""),
expiration=body.get("expiration"),
note=body.get("note", ""),
)
except SecretExisteError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
_audit(
_username(current_user),
"secret_cree",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="ok",
)
return _metadata_secret(coffre, secret)
@router.post("/api/coffres/{slug}/secrets/{nom}/rotation")
async def api_rotate_secret(
slug: str, nom: str, body: dict = Body(...), current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
secret = rotate_secret(coffre, nom, body.get("valeur", ""))
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
_audit(
_username(current_user),
"secret_rotation",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="ok",
)
return _metadata_secret(coffre, secret)
@router.delete("/api/coffres/{slug}/secrets/{nom}")
async def api_delete_secret(
slug: str, nom: str, current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
secret, _octrois = delete_secret(coffre, nom)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_audit(
_username(current_user),
"secret_supprime",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="ok",
)
return {"status": "deleted", "nom": secret["nom"]}
@router.get("/api/coffres/{slug}/octrois")
async def api_list_octrois(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
return list_octrois(coffre["id"])
@router.post("/api/coffres/{slug}/octrois")
async def api_create_octroi(
slug: str, body: dict = Body(...), current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
secret_id = None
if body.get("secret_nom"):
secret = _secret_or_404(coffre, body["secret_nom"])
secret_id = secret["id"]
try:
octroi = create_octroi(
coffre=coffre,
cible_type=body.get("cible_type", ""),
cible_id=body.get("cible_id", ""),
secret_id=secret_id,
cree_par=_username(current_user),
expiration=body.get("expiration"),
)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"octroi_cree",
coffre=coffre["slug"],
secret_ref=(
f"coffre:{coffre['slug']}/{body.get('secret_nom', '*')}"
),
resultat="ok",
)
return octroi
@router.delete("/api/coffres/octrois/{octroi_id}")
async def api_revoke_octroi(octroi_id: str, current_user=Depends(require_auth)):
_exiger_actives()
# Le gestionnaire du coffre concerné (ou l'admin d'instance) seul peut
# révoquer : on résout le coffre depuis l'octroi avant toute action.
octroi = get_octroi(octroi_id)
if octroi is None:
raise HTTPException(status_code=404, detail="Octroi introuvable")
coffre = get_coffre_by_id(octroi["coffre_id"])
if coffre is None:
raise HTTPException(status_code=404, detail="Coffre introuvable")
_exiger_gerer(current_user, coffre)
try:
octroi = revoquer_octroi(octroi_id)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_audit(
_username(current_user),
"octroi_revoque",
coffre=coffre["slug"],
resultat="ok",
)
return octroi
@router.get("/api/coffres/{slug}/journal")
async def api_journal(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
# Défense en profondeur : même si une entrée contenait un jour un champ
# sensible, il ne sort jamais par cette route.
nettoyees = []
for entry in get_recent_entries(limit=100):
if entry.get("coffre") != slug:
continue
nettoyees.append(
{
k: v
for k, v in entry.items()
if k not in ("valeur", "ciphertext", "nonce", "jeton", "dek_chiffree")
}
)
return nettoyees
# ---------------------------------------------------------------------------
# Administration KEK / rotations (admin seulement)
# ---------------------------------------------------------------------------
@router.post("/api/coffres/admin/generer-kek")
async def api_generer_kek(current_user=Depends(require_admin)):
_exiger_actives()
# La KEK générée n'est rendue qu'une fois, ici, et jamais stockée par
# ObsiGate : l'opérateur doit la sauvegarder hors dépôt immédiatement.
return {"kek": generate_kek_b64()}
@router.post("/api/coffres/{slug}/rotation-dek")
async def api_rotate_dek(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
coffre = rotate_dek(slug)
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
_audit(_username(current_user), "dek_rotation", coffre=coffre["slug"],
resultat="ok")
return _metadata_coffre(coffre)
@router.post("/api/coffres/admin/rotation-kek")
async def api_rotate_kek(
body: dict = Body(...), current_user=Depends(require_admin)
):
_exiger_actives()
try:
count = rotate_kek(body.get("nouvelle_kek", ""))
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(_username(current_user), "kek_rotation", resultat="ok")
return {"status": "ok", "coffres_rechiffres": count}
+84
View File
@@ -0,0 +1,84 @@
"""Endpoints des références croisées des notes — Fusion #215 (ACC-1.3).
Création par collage d'une URL profonde d'une autre application
(Flowdeck, TikTak, Kanbante, ObsiGate) — zéro couplage serveur :
l'URL est reconnue par les formes figées du contrat
``dracodev-commons/contrats/liens-profonds.md`` (voir
:mod:`backend.external_refs`), la référence est stockée chez la note
propriétaire et affichée dans sa section « Liens ».
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from backend.auth.middleware import check_vault_access, require_auth
from backend.external_refs import add_ref, delete_ref, get_ref, list_refs, parse_deep_link
from backend.schemas import ExternalRef, ExternalRefCreateRequest, ExternalRefsResponse
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["ExternalRefs"])
_FORMES_ACCEPTEES = (
"URL non reconnue — formes acceptées : TikTak ?task= ou ?list=, "
"ObsiGate #file=, Flowdeck /pages/ ou /collections/, "
"issue Gitea (carte Kanbante)"
)
@router.get("/api/refs", response_model=ExternalRefsResponse)
async def api_list_refs(
vault: str = Query(...),
path: str = Query(...),
current_user=Depends(require_auth),
):
"""Références croisées posées sur une note."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
username = current_user.get("username") or ""
return {"refs": list_refs(username, vault, path)}
@router.post("/api/refs", response_model=ExternalRef, status_code=201)
async def api_add_ref(
payload: ExternalRefCreateRequest,
current_user=Depends(require_auth),
):
"""Crée une référence sur une note à partir d'une URL profonde collée."""
if not check_vault_access(payload.vault, current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
parsed = parse_deep_link(payload.url)
if parsed is None:
raise HTTPException(status_code=422, detail=_FORMES_ACCEPTEES)
target_app, target_type, target_id, default_label = parsed
label = (payload.label or "").strip() or default_label
username = current_user.get("username") or ""
return add_ref(
username,
payload.vault,
payload.path,
target_app,
target_type,
target_id,
payload.url.strip(),
label,
)
@router.delete("/api/refs/{ref_id}", response_model=ExternalRef)
async def api_delete_ref(
ref_id: str,
current_user=Depends(require_auth),
):
"""Retire une référence d'une note."""
username = current_user.get("username") or ""
ref = get_ref(username, ref_id)
if ref is None:
raise HTTPException(status_code=404, detail="Lien introuvable")
if not check_vault_access(ref["owner_vault"], current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
removed = delete_ref(username, ref_id)
if removed is None: # course improbable : disparue entre get et delete
raise HTTPException(status_code=404, detail="Lien introuvable")
return removed
+69 -3
View File
@@ -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,
)
@@ -128,6 +134,9 @@ async def api_file_save(
client_ip = current_user.get("_request_ip", "unknown")
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
@@ -196,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"),
@@ -245,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"),
@@ -308,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"),
@@ -376,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"),
@@ -400,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)
@@ -532,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", {
@@ -571,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"])
@@ -680,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)
@@ -725,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
@@ -742,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}
+1 -1
View File
@@ -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:
+44
View File
@@ -0,0 +1,44 @@
# backend/routers/sso.py
# Fusion ACC-3.1 (#216) — Points d'entrée SSO de l'émettrice.
#
# GET /sso/authorize?return_to=<url> : point d'entrée des autres
# applications — valide le return_to (liste blanche *.dracodev.net,
# anti open-redirect) puis redirige vers la page de connexion
# d'ObsiGate en conservant le paramètre ; après connexion, le
# frontend repart vers l'application demanderesse (le cookie
# draco_session posé à la connexion couvre tout le domaine parent).
#
# GET /api/sso/revocations : liste des jti révoqués, réservée aux
# autres applications (clé machine DRACO_SSO_MACHINE_KEY, en-tête
# X-Draco-Machine-Key) qui la mettent en cache 60 s (design §5.2).
from __future__ import annotations
import os
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from backend import draco_session
router = APIRouter(tags=["SSO"])
@router.get("/sso/authorize")
async def sso_authorize(return_to: str = ""):
target = draco_session.validate_return_to(return_to)
if target:
return RedirectResponse(
url=f"/?return_to={quote(target, safe='')}", status_code=302)
return RedirectResponse(url="/", status_code=302)
@router.get("/api/sso/revocations")
async def sso_revocations(request: Request):
machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "")
if not machine_key:
raise HTTPException(404, "Non configuré")
if request.headers.get("X-Draco-Machine-Key") != machine_key:
raise HTTPException(401, "Clé machine invalide")
return {"revoked": draco_session.revoked_list()}
+12 -12
View File
@@ -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:
+52
View File
@@ -580,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'")
@@ -605,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é)
# ---------------------------------------------------------------------------
+3 -3
View File
@@ -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:
+74
View File
@@ -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 ─────────────────────────────────────────────────────
+1 -1
View File
@@ -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()
+5 -4
View File
@@ -7,6 +7,7 @@ Single source of truth consumed by both the REST routes (``/api/vaults``,
from __future__ import annotations
from datetime import UTC
from pathlib import Path
from typing import Any
@@ -49,7 +50,7 @@ def get_vault_root(vault_name: str) -> Path:
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
"""Return the direct children of a vault directory (directories first)."""
from datetime import datetime, timezone
from datetime import datetime
from backend.indexer import SUPPORTED_EXTENSIONS
from backend.vault_settings import get_vault_setting
@@ -98,7 +99,7 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
"type": "file",
"size": stat.st_size,
"modified": stat.st_mtime,
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
"extension": entry.suffix.lower(),
})
except PermissionError:
@@ -156,7 +157,7 @@ def list_all_files(
ServiceError: ``not_found`` (404) if the directory does not exist,
``permission_denied`` (403) if it cannot be read.
"""
from datetime import datetime, timezone
from datetime import datetime
from backend.indexer import IGNORED_DIRS
@@ -215,7 +216,7 @@ def list_all_files(
"vault": vault_name,
"size": stat.st_size,
"modified": stat.st_mtime,
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
"extension": ext.lstrip(".") if ext else "",
"tags": _indexed_tags(vault_name, rel_path),
}
+5 -5
View File
@@ -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)
+2 -2
View File
@@ -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",
+46 -7
View File
@@ -14,6 +14,7 @@ import logging
import os
from typing import Any
from backend import agent_journal
from backend.indexer import get_backlinks as _get_backlinks
from backend.indexer import get_vault_names
from backend.services.backups import diff_backup as _diff_backup
@@ -58,7 +59,7 @@ from backend.services.search import advanced_search_vaults, search_vaults
from backend.services.search import list_tags as _list_tags
from backend.services.search import search_paths as _search_paths
from backend.services.vaults import browse_directory, list_accessible_vaults, list_all_files
from backend.tools.context import ToolContext, ToolRisk
from backend.tools.context import ToolContext, ToolMode, ToolRisk
from backend.tools.registry import tool
from backend.tools.schemas import (
AppendToFileInput,
@@ -360,6 +361,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
# ── D. Mutations ───────────────────────────────────────────────────────────
def _acteur_journal(ctx: ToolContext) -> str:
"""Acteur journal (#220) : ``agent:mcp`` quand l'appel vient du
serveur MCP d'ObsiGate, ``user`` pour l'assistant intégré."""
return agent_journal.ACTEUR_MCP if ctx.mode == ToolMode.MCP else agent_journal.ACTEUR_USER
@tool(
name="create_file",
description=(
@@ -374,7 +381,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
)
def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]:
"""Create a vault file (fails if it already exists)."""
return _create_file(params.vault, params.path, params.content)
result = _create_file(params.vault, params.path, params.content)
agent_journal.emettre(
agent_journal.TYPE_CREE, params.vault, result.get("path", params.path),
actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -402,7 +414,12 @@ def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str
)
def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
"""Replace a vault file's content."""
return _edit_file(params.vault, params.path, params.content)
result = _edit_file(params.vault, params.path, params.content)
agent_journal.emettre(
agent_journal.TYPE_EDITE, params.vault, result.get("path", params.path),
actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -414,7 +431,12 @@ def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
)
def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]:
"""Append content to a vault file."""
return _append_to_file(params.vault, params.path, params.content)
result = _append_to_file(params.vault, params.path, params.content)
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
agent_journal.emettre(
agent_journal.TYPE_EDITE, params.vault, chemin, actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -426,7 +448,12 @@ def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any
)
def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]:
"""Rename a vault file."""
return _rename_file(params.vault, params.path, params.new_name)
result = _rename_file(params.vault, params.path, params.new_name)
agent_journal.emettre(
agent_journal.TYPE_DEPLACE, params.vault,
result.get("new_path", params.path), actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -450,7 +477,14 @@ def rename_directory(ctx: ToolContext, params: RenameDirectoryInput) -> dict[str
)
def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]:
"""Move a vault file or directory."""
return _move_path(params.vault, params.source_path, params.destination_dir)
result = _move_path(params.vault, params.source_path, params.destination_dir)
# Journal (#220) : fichiers seulement — les dossiers sont hors catalogue.
if result.get("item_type") == "file":
agent_journal.emettre(
agent_journal.TYPE_DEPLACE, params.vault,
result.get("new_path", params.source_path), actor=_acteur_journal(ctx),
)
return result
@tool(
@@ -497,7 +531,12 @@ def replace_in_files(ctx: ToolContext, params: ReplaceInFilesInput) -> dict[str,
)
def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]:
"""Delete a vault file."""
return _delete_file(params.vault, params.path)
result = _delete_file(params.vault, params.path)
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
agent_journal.emettre(
agent_journal.TYPE_SUPPRIME, params.vault, chemin, actor=_acteur_journal(ctx),
)
return result
@tool(
+1 -1
View File
@@ -244,7 +244,7 @@ class VaultWatcher:
key = event.get('dest') or event['src']
pending[key] = event
except asyncio.TimeoutError:
except TimeoutError:
if pending:
events_to_process = list(pending.values())
pending.clear()
+4 -4
View File
@@ -31,7 +31,7 @@ import os
import socket
import threading
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import urlparse
@@ -275,7 +275,7 @@ def create_webhook(
"url": url,
"events": [e for e in events if e in VALID_EVENTS],
"enabled": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"created_at": datetime.now(UTC).isoformat(),
"last_fired_at": None,
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
"vault": _normalize_vault_filter(vault),
@@ -344,7 +344,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
payload = {
"event": event_type,
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"data": data,
}
body = json.dumps(payload, default=str)
@@ -369,7 +369,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
else:
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
update_webhook(wh["id"], {"last_fired_at": datetime.now(timezone.utc).isoformat()})
update_webhook(wh["id"], {"last_fired_at": datetime.now(UTC).isoformat()})
except Exception as e:
logger.warning(f"Webhook '{wh['name']}' error: {e}")
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.68.2"
version = "2.81.3"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.68.2"
version = "2.81.3"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.68.2",
"version": "2.81.3",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
-2
View File
@@ -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
+156
View File
@@ -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.
+390
View File
@@ -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.
+155
View File
@@ -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.
+15 -2
View File
@@ -220,8 +220,13 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *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 |
| *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 |
| | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches)
| *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 |
|---|---|---|---|---|---|---|---|---|---|
@@ -347,7 +352,15 @@ 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-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) |
---
+235 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.68.2 | **Dernière mise à jour :** 2026-10-14
> **Version :** 2.81.3 | **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
@@ -155,6 +374,18 @@
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)
@@ -935,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) |
---
+97
View File
@@ -0,0 +1,97 @@
# #223 — Dev/prod sans noms publics en dur : URLs de l'écosystème par configuration
> Ouvert le 2026-10-25 (chantier dev/prod de Bruno). Contexte : l'installation
> actuelle (`deepin.lab.home`) devient l'environnement **dev** (noms en `-dev`),
> la future installation (`jump.point.mint.home`) sera la **prod** (noms
> officiels sans suffixe), identique sauf noms et clés. Règle : aucun nom
> public de l'écosystème codé en dur dans le code vivant ; défauts = noms
> officiels ; le dev déclare ses `-dev` dans son seul `.env`.
## Configuration (nouveau module `backend/draco_config.py`)
Lecture à l'appel, jamais à l'import. Défauts officiels :
| Variable | Défaut officiel | Rôle |
|---|---|---|
| `DRACO_AGENT_URL` | `https://agent.dracodev.net` | Base du service d'agents. À défaut : origine dérivée d'`AGENT_EVENEMENTS_URL`, puis défaut. |
| `DRACO_OBSIGATE_URL` | `https://og.dracodev.net` | Liens profonds ObsiGate |
| `DRACO_FLOWDECK_URL` | `https://flowdeck.dracodev.net` | Liens profonds Flowdeck |
| `DRACO_TIKTAK_URL` | `https://tiktak.dracodev.net` | Liens profonds TikTak |
| `DRACO_KANBANTE_URL` | `https://kanbante.dracodev.net` | Onglet Kanbante de la barre |
| `DRACO_GITEA_URL` | `https://git.dracodev.net` | Cartes Kanbante (= issues Gitea). Distincte d'`OBSIGATE_GITEA_URL` (source connectée de l'assistant). |
`AGENT_EVENEMENTS_URL` (existant, #220) reste la configuration première du
journal d'événements ; `agent_journal._url()` retombe sur
`DRACO_AGENT_URL + /api/v1/evenements` quand elle est absente.
## Backend
- **CSP** (`backend/main.py`, `SecurityHeadersMiddleware`) : `connect-src` et
`frame-src` utilisent `draco_config.agent_origine()` — dérivée de la config
à chaque réponse, plus aucun littéral. Tests dans
`tests/test_security_headers.py` **via la config** (défaut officiel neutralisé,
dérivation depuis `AGENT_EVENEMENTS_URL`, priorité `DRACO_AGENT_URL`).
- **Références croisées** (`backend/external_refs.py`) :
- `parse_deep_link` reconnaît les hôtes officiels **et** configurés
(`draco_config.hotes_reconnus()`) — un lien collé depuis le dev comme
depuis la prod est reconnu.
- `build_deep_link(app, type, id)` construit l'URL d'affichage depuis la
config ; `list_refs` / `get_ref` / `delete_ref` servent des copies dont
`target_url` est **reconstruite à la lecture**. ⚠️ Les références
**stockées** (`data/external_refs/*.json`) gardent l'URL absolue collée
par l'utilisateur : aucune migration, la valeur stockée n'est simplement
plus jamais servie telle quelle.
- **WebAuthn — confirmé, aucun changement** : `resolve_relying_party()`
(`backend/auth/webauthn_mfa.py`, BUG-070) dérive `rp_id`/origines de la
requête (hôte exact, `X-Forwarded-Host`/`X-Forwarded-Proto` honorés si
`OBSIGATE_TRUST_PROXY=true`), et `OBSIGATE_WEBAUTHN_RP_ID` /
`OBSIGATE_WEBAUTHN_ORIGINS` priment quand ils sont posés. Aucun nom figé.
## Frontend (contrat d'injection de dracodev-commons)
L'hôte fournit les URL : `backend/main.py::_index_html()` injecte
`window.DRACO_URLS = {…}` (JSON issu de `draco_config.urls_publiques()`,
nonce CSP par le mécanisme existant) dans `index.html` au service.
- `frontend/draco-panneau.js` : `API = window.DRACO_URLS.agent || défaut officiel` ;
`appHote()` retire un suffixe `-dev` éventuel du premier label d'hôte
(`og-dev` → `obsigate` pour le lien profond `?app=`).
- `frontend/draco-liens.js` : « Ouvrir dans TikTak » =
`window.DRACO_URLS.tiktak || défaut officiel`.
- `frontend/index.html` : les `href` des onglets portent les défauts officiels ;
le script de configuration de la barre les réécrit depuis `DRACO_URLS`.
Le menu des apps (`draco-barre.js`) est construit depuis ces onglets : il
suit donc sans changement.
- Chemin réellement servi : `/static/draco-*.js` via le montage `StaticFiles`,
`Cache-Control: no-cache` posé par `SecurityHeadersMiddleware` sur
`/static/*`, et service worker **network-first** pour le code — aucun
cache-busting par empreinte n'existe (revalidation systématique).
## Correctif BUG-120 (2026-10-25) — dérivation d'environnement des sœurs
Constat sur la dev : le `.env` du poste ne déclare que l'agent (via
`AGENT_EVENEMENTS_URL`) ; les quatre `DRACO_<APP>_URL` absentes
retombaient sur les défauts prod dans `window.DRACO_URLS` — le menu
inter-apps pointait la prod depuis la dev. La résolution d'une URL
sœur (`backend/draco_config.py::app_url`) gagne une étape
intermédiaire, entre l'explicite et le défaut officiel :
**dérivation depuis l'hôte de l'agent** (`agent_url()`) — hôte de la
forme `agent<SUFFIXE>.dracodev.net` sans port → sœur =
`https://<slug><SUFFIXE>.dracodev.net` (slugs : `og`, `flowdeck`,
`tiktak`, `kanbante`). Hôte hors motif (autre domaine, LAN, IP,
port) → aucune dérivation. Gitea exclue. Le dev n'a plus quatre
variables à déclarer ; un `.env` qui les déclare déjà est inchangé
(l'explicite prime).
## Restes assumés
- Suffixe SSO `dracodev.net` (`draco_session.validate_return_to`,
`frontend/js/auth.js`) : ce n'est pas un nom d'hôte mais le domaine parent
commun aux deux environnements (les noms `-dev` restent des sous-domaines) ;
le domaine du cookie est déjà configurable (`DRACO_SESSION_COOKIE_DOMAIN`).
- Mentions textuelles (titres/messages du panneau, contact OpenAPI et lien
« À propos » vers le dépôt Git) : infra Git hors périmètre, ou libellés.
- `OBSIGATE_SEARXNG_URL` : déjà configurable, défaut officiel conservé.
- Le panneau reste un asset partagé : la même ligne d'injection doit être
recopiée dans les 3 autres apps par leurs chantiers respectifs pour
retrouver l'octet-identique.
@@ -0,0 +1,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.**
+38
View File
@@ -0,0 +1,38 @@
# #218 — Fusion : panneau Spark intégré (agent commun)
**Contexte.** Le programme Fusion a livré le service d'agents commun
(agent-commun : conversation LLM, lectures/écritures des quatre
applications via approbations, sous-agents, missions longues). Trois
applications affichaient déjà son panneau partagé (TikTak, Flowdeck,
Kanbante) ; ObsiGate, pourtant émettrice de l'identité `draco_session`,
gardait le message d'attente « arrive en phase 4 » sur le bouton Spark
de sa barre commune (#215).
**Livraison.**
- Assets vendangés à l'identique des autres applications :
`frontend/draco-panneau.js` et `frontend/draco-panneau.css`, à la
racine de `frontend/` comme les autres assets draco (servis par le
montage `/static`, `Cache-Control: no-cache` déjà en place — aucun
mécanisme de versionnage à ajouter, `index.html` n'est pas un
gabarit Jinja).
- `frontend/index.html` : lien CSS après celui de la barre, script
différé après `draco-liens.js`, action `spark` de
`DRACO_BAR_CONFIG.onAction` branchée sur
`window.DRACO_PANNEAU.toggle()` avec repli explicite si l'asset ne
charge pas.
- CSP (`backend/main.py`, `SecurityHeadersMiddleware`) :
`https://agent.dracodev.net` ajouté à `connect-src` — le panneau
appelle le service en `fetch` avec le cookie de session ; aucun
autre hôte n'est ouvert (précédent : Flowdeck, même exception).
**Vérifications.** Tests `tests/test_draco_barre.py` étendus
(présence et références des assets, branche spark, hôte CSP) ;
vérification visuelle et E2E à confirmer après redéploiement Docker
(le panneau, `z-index: 400`, cohabite avec des surfaces ObsiGate à
z-index plus élevé — à observer en réel).
**Hors périmètre.** Les textes du panneau vivent dans l'asset partagé
du programme (français, identique dans les quatre applications) ; ils
ne passent pas par les locales FR/EN d'ObsiGate — écart assumé au
niveau du programme, pas de cette fiche.
@@ -0,0 +1,29 @@
# #219 — Fusion : panneau Spark v2 (Lot 1)
**Contexte.** Le panneau Spark livré en 2.74.0 (#218) affichait les
réponses de l'agent en texte brut (les `##` et `**` du Markdown
restaient visibles), avait une largeur fixe, un thème clair codé en
dur déconnecté de l'application hôte, et une seule conversation sans
gestion. Le Lot 1 du « Panneau v2 » (carte fusion-trois-apps #17,
arbitrage de Bruno du 2026-10-22) corrige ces quatre points, à
l'identique dans les quatre applications du programme.
**Livraison (côté ObsiGate : assets vendangés uniquement).**
- `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
remplacés par la v2 partagée (octet pour octet avec TikTak,
Flowdeck et Kanbante) :
- **Markdown rendu** : les réponses et les rapports de missions
affichent le HTML produit par le service d'agents (0.10.0 :
mistune + sanitizer côté serveur) ;
- **largeur réglable** à la souris (poignée, persistée,
double-clic = largeur par défaut) ;
- **thème clair/sombre aligné** sur l'application hôte
(`html[data-theme]`, ici celui d'ObsiGate, suivi en direct) ;
- **discussions façon Muse** : discussion principale
perpétuelle + discussions parallèles créées, archivées et
supprimées depuis le tiroir du panneau.
- Le travail de fond est dans le service d'agents (agent-commun
0.10.0) ; ObsiGate ne change aucun code propre. Le `/static` est
déjà servi en `Cache-Control: no-cache` : aucun versionnage
d'asset à ajouter. Redéploiement Docker requis.
@@ -0,0 +1,40 @@
# #225 — Fusion : panneau Spark, version du service d'agents visible en mode sidebar
**Contexte.** La version « Service d'agents vX » du service d'agents
est visible dans la console pleine page (pilule d'en-tête, rendue par
le service lui-même), mais pas dans le **panneau natif des
applications** (mode sidebar) — or c'est là que Bruno vit au
quotidien. Demande de Bruno (2026-10-26), carte fusion #64 : la
version doit se lire dans le panneau ouvert, sans ouvrir la console.
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
maintenu en canonique dans TikTak et recopié dans les 4 applications
(TikTak, Flowdeck, Kanbante, ObsiGate ; chaque copie garde son bloc
d'injection d'origine propre à l'hôte, le corps est commun) :
- le pied du panneau gagne une ligne dédiée
`.draco-panneau-version`, discrète (11,5 px, opacité 0,6,
monochrome, aucun emoji), **masquée tant qu'aucune version n'est
connue** ;
- à l'ouverture du panneau, `chargerVersion()` interroge la santé
du service — `GET /healthz` de l'origine que le panneau connaît
déjà (celle injectée par l'hôte, ici `window.DRACO_URLS.agent`) —
et affiche « Service d'agents vX » depuis le champ `version` de
la réponse (la même source que la pilule de la console) ;
- cache de session de 5 minutes, rafraîchissement au rythme des
ouvertures du panneau (comme la vérification d'identité) ;
- **jamais de valeur codée en dur** : réponse illisible ou service
injoignable = la ligne reste masquée — pas de fausse version, pas
d'erreur visible.
**Preuve.** Épreuve comportementale (jsdom) exécutée sur les 4
copies avec le contrat d'injection de chaque hôte : santé joignable
→ la ligne affiche « Service d'agents v0.38.0 » (version lue sur
l'origine de l'agent) ; santé injoignable → rien d'affiché ; hôte
sans injection → aucun appel. Test de garde :
`tests/test_draco_barre.py::test_panneau_version_service_affichee_225`.
**Même livraison** (carte fusion #64) : TikTak 0.61.3, Flowdeck
7.83.1, Kanbante 0.37.1. Côté ObsiGate, la prise d'effet attend le
**redéploiement Docker** (geste de Bruno), comme les items panneau
précédents.
@@ -0,0 +1,94 @@
# #224 — Sécurité des dépendances : pip-audit vierge (brotli, filelock, opentelemetry-api, pypdf)
> Ouvert le 2026-10-25. Déclencheur : le job `security` du run CI 2303
> (commit `f29a228`, version 2.78.0) échoue sur `pip-audit` (bloquant,
> #87) — **17 vulnérabilités connues dans 4 paquets**. Le job `lint`
> du même run est vert ; `security` est le seul job terminé en échec
> au moment du constat (`test` en file, `build`/`e2e` en attente).
## Constat (journal du job `security`, run 2303)
| Paquet | Version auditée | Vulnérabilités | Corrigée en |
|---|---|---|---|
| brotli | 1.2.0 | PYSEC-2026-4322, GHSA-9v8h-qqgm-4m35 | 1.2.1 |
| filelock | 3.32.3 | CVE-2026-112940, CVE-2026-112913, CVE-2026-112907 | 4.2.1 |
| opentelemetry-api | 1.37.0 | CVE-2026-114603 | 1.45.1 |
| pypdf | 6.19.0 | CVE-2026-113792, CVE-2026-113795, CVE-2026-113793, CVE-2026-113794, CVE-2026-113786, CVE-2026-113791, CVE-2026-113790, CVE-2026-113789, CVE-2026-115572, CVE-2026-115585, CVE-2026-115580 | 6.20.0 → 6.21.2 selon l'advisory (max 6.21.2) |
Total : 2 + 3 + 1 + 11 = **17 vulnérabilités**.
Audit local de confirmation, en deux temps : (1) un venv épinglé sur
les versions du runner (brotli 1.2.0, filelock 3.32.3,
opentelemetry-api 1.37.0, pypdf 6.19.0) reproduit **exactement** les
17 vulnérabilités et les versions corrigées du tableau ci-dessus ;
(2) dans le venv du dépôt, `pip-audit` (avec les deux ignores déjà en
place dans la CI) ne ressortait avant correctif que brotli 1.2.0
(2 advisories, fix 1.2.1) et pypdf 6.21.1 (3 advisories restants,
fix 6.21.2) — ce venv a déjà filelock 4.2.4 (corrigée) et ne contient
pas opentelemetry-api. L'écart avec la CI vient de l'environnement
audité, pas des dépendances déclarées (voir cause).
## Cause racine
Même piège que BUG-093 / BUG-095, déjà documenté dans
`backend/requirements.txt` et `.gitea/workflows/ci.yml` : `pip-audit`
audite **tout l'environnement Python du runner**, dont les paquets
préinstallés dans la toolcache de l'image `catthehacker/ubuntu:act-latest`.
`pip install -r backend/requirements.txt` répond « already satisfied »
pour toute exigence transitive déjà satisfaite par la toolcache et
n'aligne jamais la version vulnérable :
- **pypdf 6.19.0** : préinstallée ; le plancher `>=6.16.1` (BUG-093)
est satisfait, pip ne monte pas vers 6.21.2.
- **brotli 1.2.0** : transitive de `fonttools[woff]` (via weasyprint),
exigée en `>=1.0.1` seulement.
- **filelock 3.32.3** : transitive de CacheControl (outil `pip-audit`
lui-même) et préinstallée ; exigée en `>=3.8.0` seulement.
- **opentelemetry-api 1.37.0** : préinstallée dans la toolcache,
aucune dépendance d'ObsiGate ne la demande.
## Correctif
Planchers de sécurité explicites dans `backend/requirements.txt`,
selon le patron déjà en place (pyjwt, urllib3, multidict, certifi) —
aucune autre dépendance touchée, aucun code applicatif modifié :
- `pypdf>=6.16.1` → **`pypdf>=6.21.2`** (plancher existant relevé) ;
- **`brotli>=1.2.1`** (nouveau plancher) ;
- **`filelock>=4.2.1`** (nouveau plancher) ;
- **`opentelemetry-api>=1.45.1`** (nouveau plancher).
Garde-fou : `tests/test_ci_workflow.py::TestDependencySecurityFloors`
couvre désormais aussi brotli, filelock et opentelemetry-api, et le
plancher verrouillé de pypdf passe à (6, 21, 2). Le commentaire de
l'étape Pip-audit de `.gitea/workflows/ci.yml` est mis à jour en
conséquence (aucune commande CI modifiée).
## Exceptions (ignores)
**Aucun nouvel ignore.** Les deux exceptions déjà en place dans la
CI restent inchangées et ne concernent pas ces paquets :
`PYSEC-2026-1325` (ecdsa, aucun correctif upstream, chemins ECDSA
jamais exécutés — HS256 uniquement) et `CVE-2026-85394`
(python-jose, aucun correctif upstream, non atteignable — voir le
commentaire de l'étape dans `ci.yml`).
## Vérification
- `pip-audit` local (mêmes ignores que la CI) après alignement de
l'environnement sur les nouveaux planchers : **0 vulnérabilité
non ignorée**.
- Portes locales du workflow : `pytest tests/`, `ruff check backend/`,
`mypy backend/ --ignore-missing-imports`, tests frontend
(`validate-imports` + `unit`) — voir le commit #224.
- CI Gitea complète verte exigée (lint, test, security, build, e2e)
avant de considérer #224 livré.
## Livraison
- Commit conventionnel `fix: … #224` ; le hook de version porte
2.78.0 → **2.78.1** (correctif) et crée le tag `v2.78.1`, publié
au push ; release Gitea créée sur ce tag après vérification de sa
cible.
- **Redéploiement Docker requis** pour que l'image embarque les
dépendances alignées (l'hôte actuel est l'environnement dev).
+4 -4
View File
@@ -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;
}
+7
View File
@@ -3424,6 +3424,13 @@
// Initialize outline/TOC for this document
OutlineManager.init();
// Fusion #215 (ACC-1.2/1.3) — point d'extension : le module
// draco-liens.js rend la section « Liens » de la note
// (références croisées + « ouvrir dans TikTak » si projetée).
document.dispatchEvent(new CustomEvent("obsigate:file-rendered", {
detail: { vault: data.vault, path: data.path },
}));
}
// ---------------------------------------------------------------------------
+193
View File
@@ -0,0 +1,193 @@
/* 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 : menus « ••• » et compte (construits par le JS, mêmes jetons
que le menu des applications) ; l'avatar est un vrai <button>.
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-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; }
/* Menus « ••• » et compte (v0.1.6) — même boîte que le menu des apps,
* ancrés à droite ; leurs items réutilisent .draco-apps-item. */
.draco-more-menu, .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-more-menu[hidden], .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, les domaines et le menu « ••• »
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; }
}
+491
View File
@@ -0,0 +1,491 @@
/* 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 « ••• » par défaut et menu compte fonctionnels, voir ci-dessous).
*
* 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 ;
* c'est le moyen de changer d'app en mobile, où les onglets sont
* repliés (v0.1.2). Menu « ••• » et compte : voir ci-dessous.
*
* Menu « ••• » (v0.1.6) — audit des hôtes : ObsiGate est le seul hôte
* connu à gérer l'action "menu" via onAction (il déclenche son propre
* bouton d'en-tête #header-menu-btn) ; les autres hôtes ne la gèrent
* pas et le bouton restait mort (relais vers un onAction absent, puis
* événement draco:bar sans écouteur). Comportement désormais :
* - sans onAction : un menu par défaut FONCTIONNEL s'ouvre — accès à
* la console agent (URL agent résolue, donc dérivée -dev sur un
* hôte -dev) et aux quatre applications (URL résolues) ; fermeture
* par clic extérieur, Échap ou choix d'un lien ;
* - avec onAction : l'hôte garde la main (comportement historique,
* zéro régression pour ObsiGate) ; il peut demander explicitement
* le menu par défaut en retournant false pour l'action "menu".
* 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 moreBtn = bar ? bar.querySelector('[data-act="menu"]') : null;
var avatarBtn = document.querySelector(".draco-avatar");
var appsMenu = null, moreMenu = null, compteMenu = 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() {
if (appsMenu) togglePopup(appsMenu, brand, false);
if (moreMenu) togglePopup(moreMenu, moreBtn, false);
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
}
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 : "";
}
function menuLink(app, nom, role) {
var a = document.createElement("a");
a.className = "draco-apps-item draco-menu-item";
a.href = resolvedUrls[app] || "#";
a.dataset.app = app;
a.setAttribute("role", "menuitem");
var txt = document.createElement("span");
txt.className = "draco-apps-item-txt";
var n = document.createElement("span");
n.className = "draco-apps-item-nom";
n.textContent = nom;
txt.appendChild(n);
if (role) {
var r = document.createElement("span");
r.className = "draco-apps-item-role";
r.textContent = role;
txt.appendChild(r);
}
a.appendChild(txt);
return a;
}
/* 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);
});
bar.appendChild(appsMenu);
}
function toggleAppsMenu(force) {
buildAppsMenu();
if (!appsMenu) return;
if (force !== false) refreshMenuHrefs(appsMenu);
togglePopup(appsMenu, brand, force);
}
/* Menu « ••• » par défaut (v0.1.6) — console agent + applications.
* Construit depuis les URL résolues (donc dérivées -dev sur un hôte
* -dev) ; l'hôte qui gère "menu" via onAction garde la main (voir
* l'en-tête) et ne voit jamais ce menu, sauf s'il retourne false. */
function buildMoreMenu() {
if (!bar || !moreBtn || moreMenu) return;
moreMenu = document.createElement("div");
moreMenu.className = "draco-more-menu";
moreMenu.hidden = true;
moreMenu.setAttribute("role", "menu");
moreMenu.setAttribute("aria-label", "Plus d'actions");
moreMenu.appendChild(menuLink("agent", "Console agent", "Agent commun"));
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
moreMenu.appendChild(sep);
["obsigate", "flowdeck", "tiktak", "kanbante"].forEach(function (app) {
moreMenu.appendChild(menuLink(app, tabLabel(app), tabRole(app)));
});
bar.appendChild(moreMenu);
}
function toggleMoreMenu(force) {
buildMoreMenu();
if (!moreMenu) return;
if (force !== false) refreshMenuHrefs(moreMenu);
togglePopup(moreMenu, moreBtn, force);
}
/* 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);
}
/* 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 } }));
}
document.addEventListener("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 (moreMenu) toggleMoreMenu(false);
if (compteMenu) toggleCompteMenu(false);
toggleAppsMenu();
return;
}
if (el && el.dataset.act === "menu") {
if (appsMenu) toggleAppsMenu(false);
if (compteMenu) toggleCompteMenu(false);
/* Hôte prioritaire : il garde la main, sauf s'il demande
* explicitement le menu par défaut en retournant false.
* Sans onAction, le menu par défaut s'ouvre (il ne restait
* auparavant qu'un événement draco:bar sans écouteur). */
if (typeof cfg.onAction === "function") {
var handled = cfg.onAction("menu", el, ev);
if (handled === false) toggleMoreMenu();
} else {
toggleMoreMenu();
}
return;
}
if (el && el.dataset.act === "compte") {
if (appsMenu) toggleAppsMenu(false);
if (moreMenu) toggleMoreMenu(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-more-menu") || ev.target.closest(".draco-compte-menu")) : null;
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (moreMenu && !moreMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
closeAllPopups();
}
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
relay(act, el, ev);
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") closeAllPopups();
});
/* 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();
buildMoreMenu();
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
})();
+193
View File
@@ -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);
});
})();
+261
View File
@@ -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
+30 -9
View File
@@ -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);
+142 -5
View File
@@ -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,8 @@
@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-panneau.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
@@ -222,7 +224,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 +331,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&#39;application" title="Changer d&#39;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-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu" aria-expanded="false">•••</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 et Spark arrivent avec la
fédération / l'agent commun (phases suivantes). */
/* #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; }
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
if (act === "spark") {
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">
@@ -5931,5 +6065,8 @@ curl -X POST https://votre-serveur.com/webhook \
</div>
</div>
</div>
<script defer src="/static/draco-barre.js"></script>
<script defer src="/static/draco-liens.js"></script>
<script defer src="/static/draco-panneau.js"></script>
</body>
</html>
+19
View File
@@ -121,6 +121,25 @@ const AuthManager = {
if (tokenData.user) {
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
}
this._honorReturnTo();
},
// Fusion ACC-3.1 (#216) : après connexion chez l'émettrice SSO,
// repartir vers l'application demanderesse. Le return_to est
// re-validé ici (https + domaine dracodev.net, jamais l'hôte
// courant) en plus de la validation serveur de /sso/authorize.
_honorReturnTo() {
try {
const rt = new URLSearchParams(window.location.search).get("return_to");
if (!rt) return;
const u = new URL(rt);
const okHost = u.hostname === "dracodev.net"
|| u.hostname.endsWith(".dracodev.net");
if (u.protocol === "https:" && okHost
&& u.hostname !== window.location.hostname) {
window.location.replace(rt);
}
} catch (e) { /* return_to illisible : on reste sur place */ }
},
getToken() {
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -239,9 +239,14 @@ async function onDrop(e) {
e.preventDefault();
e.stopPropagation();
const isExternalFiles = e.dataTransfer && (
// BUG-117: 'items.length > 0' comptait aussi les formats STRING (tree:vault/path/
// type) ajoutés par setData au dragstart interne → tout déplacement interne était
// classé à tort « upload externe » et avalé silencieusement. Seuls les items de
// kind 'file' (ou le type 'Files', ou files non vide) désignent un drop externe,
// et un dragState actif (drag interne) prime toujours.
const isExternalFiles = !dragState && e.dataTransfer && (
(e.dataTransfer.files && e.dataTransfer.files.length > 0) ||
(e.dataTransfer.items && e.dataTransfer.items.length > 0) ||
Array.from(e.dataTransfer.items || []).some((it) => it.kind === 'file') ||
Array.from(e.dataTransfer.types || []).includes('Files')
);
+514
View File
@@ -0,0 +1,514 @@
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68).
*
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
*
* Contrat d'API (audité, rien d'inventé) :
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8
* -> { results: [{ vault, path, title, snippet }] }
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
* - Flowdeck : GET {base}/api/search?q=
* -> { pages: [{ title, excerpt, subtitle, url: "/pages/<id>" }],
* collections: [{ title, subtitle, url: "/db/<id>" }] }
* - TikTak : GET {base}/api/search?q=
* -> { tasks: [{ id, title, description, status, due_at }], ... }
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search).
* - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison)
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
* adressable — même limite que la page /search native de Kanbante).
*
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
* hôte de la requête > dérivation > défaut officiel) ;
* 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ;
* 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ;
* 4. href des onglets .draco-apptab rendus ;
* 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev).
*
* Authentification cross-origin : les appels sœurs partent avec
* credentials:"include". Le navigateur envoie alors les cookies de
* chaque origine, dont le cookie SSO partagé draco_session
* (Domain=.dracodev.net), accepté par les 4 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette
* livraison). Chaque sœur n'autorise en CORS que les origines sœurs
* (GET sur l'endpoint de recherche uniquement, pas de "*").
* Une sœur injoignable / non autorisée ne casse pas les autres :
* sa section affiche l'erreur + un lien vers sa recherche native.
*/
(function (global) {
"use strict";
/* globalThis : le fichier est chargé comme script classique dans les
* navigateurs, requis en CJS par les tests TikTak/Kanbante/Flowdeck,
* et importé en ESM par le test ObsiGate (frontend/js est "type":
* "module") — où `this` de niveau supérieur vaut undefined. */
var PROD = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
};
var DEV = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
};
var APPS = [
{ key: "obsigate", label: "ObsiGate" },
{ key: "flowdeck", label: "Flowdeck" },
{ key: "tiktak", label: "TikTak" },
{ key: "kanbante", label: "Kanbante" },
];
var COLUMN_LABELS = {
backlog: "Backlog",
in_progress: "En cours",
qa: "QA",
review: "Relecture",
todo: "À faire",
done: "Terminé",
};
var TIMEOUT_MS = 8000;
var DEBOUNCE_MS = 250;
var PER_APP_LIMIT = 8;
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function cleanUrl(u) {
if (!u) return null;
u = String(u).trim();
if (!u || u.indexOf("{{") !== -1) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) === "/" ? u : u + "/";
}
/* Même règle que la barre v0.1.6 : première étiquette en -dev ET
* domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
/* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */
function resolveBases(opts) {
opts = opts || {};
var dev = isDevHost(opts.host);
var out = {};
APPS.forEach(function (app) {
var key = app.key;
var u = cleanUrl(opts.explicit && opts.explicit[key]);
if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]);
if (!u && dev) u = DEV[key];
if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]);
if (!u) u = PROD[key];
out[key] = withSlash(u);
});
return out;
}
function searchPath(app, q) {
if (app === "obsigate") {
return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT;
}
return "api/search?q=" + encodeURIComponent(q);
}
function snippetOf(s, n) {
s = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s;
}
/* Normalise la réponse d'une app en [{ title, snippet, context, url }].
* base : base résolue de l'app (avec / final). q : requête (liens TikTak). */
function normalize(app, data, base, q) {
data = data || {};
if (app === "obsigate") {
return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) {
return {
title: r.title || r.path || "Sans titre",
snippet: snippetOf(r.snippet || ""),
context: (r.vault || "") + (r.path ? " · " + r.path : ""),
url:
base +
"#file=" +
encodeURIComponent(r.vault || "") +
":" +
encodeURIComponent(r.path || ""),
};
});
}
if (app === "flowdeck") {
var items = (data.pages || []).concat(data.collections || []);
return items.slice(0, PER_APP_LIMIT).map(function (r) {
var rel = String(r.url || "").replace(/^\//, "");
return {
title: r.title || "Sans titre",
snippet: snippetOf(r.excerpt || ""),
context: r.subtitle || (r.type === "collection" ? "Base" : "Page"),
url: base + rel,
};
});
}
if (app === "tiktak") {
return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) {
var ctx = t.status === "done" ? "Terminée" : "Active";
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
return {
title: t.title || "Sans titre",
snippet: snippetOf(t.description || ""),
context: "Tâche · " + ctx,
url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id,
};
});
}
if (app === "kanbante") {
var rows = [];
(data.groups || []).forEach(function (g) {
(g.rows || []).forEach(function (row) {
rows.push({ group: g.full_name, row: row });
});
});
return rows.slice(0, PER_APP_LIMIT).map(function (x) {
var issue = x.row.issue || {};
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
return {
title: "#" + issue.number + " " + (issue.title || "Sans titre"),
snippet: snippetOf(issue.body || ""),
context: x.group + (col ? " · " + col : ""),
url: base + "board/" + x.group,
};
});
}
return [];
}
/* Recherche native de l'app (repli quand la section est en erreur). */
function nativeSearchUrl(app, base, q) {
if (app === "tiktak" || app === "kanbante") {
return base + "search?q=" + encodeURIComponent(q || "");
}
/* ObsiGate et Flowdeck n'ont pas de page de recherche adressable :
* leur recherche native vit dans la palette (Ctrl+K) de l'app. */
return base;
}
function nativeSearchLabel(app) {
if (app === "tiktak" || app === "kanbante") {
return "Ouvrir la recherche dans " + appLabel(app);
}
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
}
function appLabel(key) {
for (var i = 0; i < APPS.length; i++) {
if (APPS[i].key === key) return APPS[i].label;
}
return key;
}
var api = {
esc: esc,
isDevHost: isDevHost,
resolveBases: resolveBases,
searchPath: searchPath,
normalize: normalize,
nativeSearchUrl: nativeSearchUrl,
PROD: PROD,
DEV: DEV,
};
if (typeof module !== "undefined" && module.exports) module.exports = api;
global.RecherchePartout = api;
/* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */
if (typeof document === "undefined") return;
function fetchSection(app, url, onDone) {
var ctrl = null;
var timer = null;
try {
ctrl = new AbortController();
timer = setTimeout(function () {
ctrl.abort();
}, TIMEOUT_MS);
} catch (e) {
ctrl = null;
}
var opts = { headers: { Accept: "application/json" }, credentials: "include" };
if (ctrl) opts.signal = ctrl.signal;
fetch(url, opts)
.then(function (resp) {
if (!resp.ok) {
var err = new Error("HTTP " + resp.status);
err.status = resp.status;
throw err;
}
return resp.json();
})
.then(function (data) {
if (timer) clearTimeout(timer);
onDone(null, data);
})
.catch(function (err) {
if (timer) clearTimeout(timer);
onDone(err, null);
});
}
function renderSection(section, state) {
/* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */
var body = section.querySelector(".rp-body");
var count = section.querySelector(".rp-count");
var toutVoir = section.querySelector(".rp-all");
toutVoir.href = nativeSearchUrl(state.app, state.base, state.q);
toutVoir.textContent = "Tout voir";
toutVoir.title = nativeSearchLabel(state.app);
if (state.status === "loading") {
count.textContent = "";
body.innerHTML = '<p class="rp-state">Recherche…</p>';
return;
}
if (state.status === "error") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state rp-error">' +
esc(state.message) +
' — <a href="' +
esc(nativeSearchUrl(state.app, state.base, state.q)) +
'">' +
esc(nativeSearchLabel(state.app)) +
"</a></p>";
return;
}
if (state.status === "empty") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
return;
}
count.textContent = String(state.items.length);
body.innerHTML = state.items
.map(function (item) {
return (
'<a class="rp-item" data-rp-link href="' +
esc(item.url) +
'">' +
'<span class="rp-title">' +
esc(item.title) +
"</span>" +
(item.context
? '<span class="rp-context">' + esc(item.context) + "</span>"
: "") +
(item.snippet
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
: "") +
"</a>"
);
})
.join("");
}
function init() {
var root = document.getElementById("recherche");
if (!root) return;
var input = document.getElementById("rp-q");
if (!input) return;
var hostApp = root.getAttribute("data-host") || "";
var explicit = {};
APPS.forEach(function (app) {
var v = root.getAttribute("data-base-" + app.key);
if (v) explicit[app.key] = v;
});
var barUrls = {};
try {
if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls;
} catch (e) {}
var tabHrefs = {};
try {
document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) {
var href = tab.getAttribute("href");
if (href) tabHrefs[tab.getAttribute("data-app")] = href;
});
} catch (e) {}
var bases = resolveBases({
host: global.location.hostname,
explicit: explicit,
barUrls: barUrls,
tabHrefs: tabHrefs,
});
var sections = {};
APPS.forEach(function (app) {
var el = document.querySelector('[data-rp-section="' + app.key + '"]');
if (el) sections[app.key] = el;
});
var currentQ = "";
var runId = 0;
var debounceTimer = null;
function runSearch(q) {
currentQ = q;
var myRun = ++runId;
if (!q) {
APPS.forEach(function (app) {
var sec = sections[app.key];
if (!sec) return;
sec.querySelector(".rp-body").innerHTML =
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
sec.querySelector(".rp-count").textContent = "";
sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], "");
});
return;
}
APPS.forEach(function (app) {
var sec = sections[app.key];
if (!sec) return;
renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q });
var url;
if (app.key === hostApp) {
/* Même origine : pas de CORS, pas de CSP cross-origin. */
url = "/" + searchPath(app.key, q);
} else {
url = bases[app.key] + searchPath(app.key, q);
}
fetchSection(app.key, url, function (err, data) {
if (myRun !== runId) return;
if (err) {
var msg;
if (err.name === "AbortError") msg = "Délai dépassé (8 s)";
else if (err.status === 401 || err.status === 403)
msg = "Connexion requise sur " + app.label;
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
renderSection(sec, {
status: "error",
message: msg,
app: app.key,
base: bases[app.key],
q: q,
});
return;
}
var items = normalize(app.key, data, bases[app.key], q);
renderSection(sec, {
status: items.length ? "ok" : "empty",
items: items,
app: app.key,
base: bases[app.key],
q: q,
});
});
});
}
function scheduleSearch(q, immediate) {
if (debounceTimer) clearTimeout(debounceTimer);
if (immediate) {
runSearch(q);
return;
}
debounceTimer = setTimeout(function () {
runSearch(q);
}, DEBOUNCE_MS);
}
function syncUrl(q) {
try {
var url = new URL(global.location.href);
if (q) url.searchParams.set("q", q);
else url.searchParams.delete("q");
global.history.replaceState(null, "", url.toString());
} catch (e) {}
}
input.addEventListener("input", function () {
var q = input.value.trim();
syncUrl(q);
scheduleSearch(q, false);
});
input.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
var first = document.querySelector("[data-rp-link]");
if (first) {
e.preventDefault();
first.click();
}
} else if (e.key === "Escape") {
input.value = "";
syncUrl("");
scheduleSearch("", true);
input.focus();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
}
});
document.addEventListener(
"keydown",
function (e) {
var target = e.target;
var typing =
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable);
if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) {
e.preventDefault();
e.stopPropagation();
input.focus();
input.select();
return;
}
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
}
},
true
);
function moveFocus(delta, e) {
var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]"));
if (!links.length) return;
var active = document.activeElement;
var idx = links.indexOf(active);
var next;
if (idx === -1) next = delta > 0 ? 0 : links.length - 1;
else next = (idx + delta + links.length) % links.length;
e.preventDefault();
links[next].focus();
}
var params = new URLSearchParams(global.location.search);
var initialQ = (params.get("q") || "").trim();
if (initialQ) {
input.value = initialQ;
runSearch(initialQ);
} else {
runSearch("");
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(
typeof window !== "undefined"
? window
: typeof globalThis !== "undefined"
? globalThis
: this
);
+177
View File
@@ -4,6 +4,7 @@ import { api, AuthManager } from './auth.js';
import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
import { openCoffresView, openCoffresNewSecret, openCoffresAcces, openCoffresJournal, getCoffresForSidebar, invalidateCoffresCache } from './coffres.js';
import { performAdvancedSearch } from './search.js';
import { t } from './i18n.js';
import { filterChatMessages as _filterChat } from './filechat.js';
@@ -46,6 +47,168 @@ function _sharedIcon(size = 16) {
return i;
}
// ---------------------------------------------------------------------------
// Coffre Secrets — nœud virtuel « Secrets » sous le Home (Phase 1c, #19).
// Patron exact du dossier virtuel « Partage » (#196) : inséré à la racine
// du Home de l'utilisateur courant seulement, sans dossier sur disque, sans
// passer par l'indexeur. Contrairement à « Partage », le nœud est permanent
// (même sans coffre) : cliquer dessus ouvre la vue Secrets, qui crée le
// coffre personnel à la première ouverture si besoin.
// AUCUNE action de fichier n'est attachée à ce nœud ni à ses enfants :
// ni attachTreeItemActionButton, ni ContextMenuManager (qui proposerait
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
// ---------------------------------------------------------------------------
/** Icône coffre-fort du nœud Secrets (SVG Safe Vault Streamline, fourni par Bruno), distincte de la maison et du diamant. Le tracé utilise currentColor pour rester lisible en thème clair comme sombre (le noir d'origine serait invisible en sombre). */
function _secretsIcon() {
const s = document.createElement("span");
s.className = "coffres-icon-lock";
s.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" aria-hidden="true"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M12.5 0.5h-11c-0.552285 0 -1 0.447715 -1 1V11c0 0.5523 0.447715 1 1 1h11c0.5523 0 1 -0.4477 1 -1V1.5c0 -0.552285 -0.4477 -1 -1 -1Z"/><path d="M8.5 8c0.9665 0 1.75 -0.7835 1.75 -1.75S9.4665 4.5 8.5 4.5s-1.75 0.7835 -1.75 1.75S7.5335 8 8.5 8Z"/><path d="M8.5 3.25V4.5"/><path d="M8.5 8v1.25"/><path d="M11.5 6.25h-1.25"/><path d="M6.75 6.25H5.5"/><path d="m10.62 4.12988 -0.88001 0.88"/><path d="m7.26 7.48999 -0.88 0.88"/><path d="m10.62 8.36999 -0.88001 -0.88"/><path d="m7.26 5.00988 -0.88 -0.88"/><path d="M3 4.5V8"/><path d="M2 12v1.5"/><path d="M11.5 12v1.5"/></g></svg>';
return s;
}
function _isOwnHome(vaultName) {
const me = (AuthManager.getUser() || {}).username;
return !!me && vaultName === `home-${me}`;
}
/** Construit le nœud Secrets + son conteneur d'enfants (coffres). */
async function _buildSecretsNode(vaultName) {
const pair = document.createDocumentFragment();
const item = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Secrets", "data-type": "virtual-secrets" }, [icon("chevron-right", 14), _secretsIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("coffres.secrets"))])]);
const sub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Secrets` });
item.addEventListener("click", async () => {
scrollTreeItemIntoView(item, false);
if (sub.classList.contains("collapsed")) {
if (sub.children.length === 0) await _fillSecretsNode(item, sub, vaultName);
sub.classList.remove("collapsed");
const chev = item.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-down");
safeCreateIcons();
} else {
sub.classList.add("collapsed");
const chev = item.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-right");
safeCreateIcons();
}
await openCoffresView();
closeMobileSidebar();
});
item.addEventListener("contextmenu", (e) => {
e.preventDefault();
_openSecretsMenu(e.clientX, e.clientY);
});
const actionBtn = el("button", { class: "tree-item-action-btn", type: "button", title: t("coffres.secrets") }, [document.createTextNode("\u22EE")]);
actionBtn.addEventListener("click", (e) => {
e.stopPropagation();
const r = actionBtn.getBoundingClientRect();
_openSecretsMenu(Math.min(r.right, window.innerWidth - 16), r.bottom);
});
item.appendChild(actionBtn);
pair.appendChild(item);
pair.appendChild(sub);
// Compteur et enfants, en arrière-plan : le nœud existe même si l'API échoue.
_fillSecretsNode(item, sub, vaultName).catch(() => {});
return pair;
}
/** Remplit le compteur et les enfants (coffres) d'un nœud Secrets existant. */
async function _fillSecretsNode(item, sub, vaultName) {
const data = await getCoffresForSidebar();
item.querySelectorAll(".badge-small, .coffres-alert-dot").forEach((n) => n.remove());
if (!data.disabled && data.personnel) {
if (!data.personnel.kek_configuree) {
const dot = document.createElement("span");
dot.className = "coffres-alert-dot";
dot.textContent = "\u26A0";
dot.title = t("coffres.kek_absente");
item.appendChild(dot);
} else {
item.appendChild(smallBadge(data.personnel.nb_secrets));
}
}
sub.innerHTML = "";
const coffres = (data.coffres || []).slice().sort((a, b) => {
const aPers = a.proprietaire === (AuthManager.getUser() || {}).username && a.perimetre === "personnel" ? 0 : 1;
const bPers = b.proprietaire === (AuthManager.getUser() || {}).username && b.perimetre === "personnel" ? 0 : 1;
return aPers - bPers || String(a.nom).localeCompare(String(b.nom));
});
coffres.forEach((c) => {
const me = (AuthManager.getUser() || {}).username;
const label = c.proprietaire === me ? c.nom : `${c.nom} (${c.proprietaire})`;
const child = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": `Secrets/${c.slug}`, "data-type": "virtual-coffre", "data-coffre-slug": c.slug }, [icon("lock", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(label)]), smallBadge(c.nb_secrets)]);
child.addEventListener("click", async () => {
scrollTreeItemIntoView(child, false);
await openCoffresView(c.slug);
closeMobileSidebar();
});
child.addEventListener("contextmenu", (e) => {
e.preventDefault();
_openSecretsMenu(e.clientX, e.clientY);
});
sub.appendChild(child);
});
safeCreateIcons();
}
/** Rafraîchit tous les nœuds Secrets affichés (après création, rotation, révocation…). */
async function _refreshSecretsNodes() {
const items = Array.from(document.querySelectorAll('[data-type="virtual-secrets"]'));
for (const item of items) {
const vaultName = item.dataset.vault;
const sub = item.nextElementSibling;
if (sub && sub.classList.contains("tree-children")) {
await _fillSecretsNode(item, sub, vaultName);
}
}
}
if (!window.__obsigateSecretsListener) {
window.__obsigateSecretsListener = true;
document.addEventListener("obsigate:coffres-changed", () => {
invalidateCoffresCache();
_refreshSecretsNodes().catch(() => {});
});
}
/** Menu ⋮ du nœud Secrets : Ouvrir, Nouveau secret, Gérer les accès, Journal.
* Menu propre, jamais ContextMenuManager (pas d'action de fichier). */
function _openSecretsMenu(x, y) {
document.querySelectorAll(".coffres-context-menu").forEach((n) => n.remove());
const menu = el("div", { class: "coffres-context-menu" }, []);
const entries = [
[t("coffres.detail"), () => openCoffresView()],
[t("coffres.new_secret"), () => openCoffresNewSecret()],
[t("coffres.acces"), () => openCoffresAcces()],
[t("coffres.journal"), () => openCoffresJournal()],
];
entries.forEach(([label, run]) => {
const b = el("button", { class: "coffres-context-item", type: "button" }, [document.createTextNode(label)]);
b.addEventListener("click", async () => {
menu.remove();
await run();
closeMobileSidebar();
});
menu.appendChild(b);
});
document.body.appendChild(menu);
const rect = menu.getBoundingClientRect();
menu.style.left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)) + "px";
menu.style.top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)) + "px";
const closer = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener("click", closer, true);
document.removeEventListener("keydown", onKey);
}
};
const onKey = (e) => { if (e.key === "Escape") { menu.remove(); document.removeEventListener("click", closer, true); document.removeEventListener("keydown", onKey); } };
setTimeout(() => {
document.addEventListener("click", closer, true);
document.addEventListener("keydown", onKey);
}, 0);
}
/** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon();
@@ -494,6 +657,12 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
}
});
// Coffre Secrets (Phase 1c) : même nœud virtuel que dans loadDirectory.
if (me && vaultName === `home-${me}` && !dirPath) {
const pair = await _buildSecretsNode(vaultName);
if (pair) fragment.insertBefore(pair, fragment.firstChild);
}
// Replace container content in a single batch operation to avoid flash
container.textContent = "";
container.appendChild(fragment);
@@ -838,6 +1007,14 @@ async function loadDirectory(vaultName, dirPath, container) {
}
});
// Coffre Secrets (Phase 1c) : nœud virtuel permanent en tête, à la racine
// du Home de l'utilisateur courant seulement, jamais sous un autre Home
// ni sous un vault de notes, et jamais comme un dossier physique.
if (me && vaultName === `home-${me}` && !dirPath) {
const pair = await _buildSecretsNode(vaultName);
if (pair) fragment.insertBefore(pair, fragment.firstChild);
}
container.appendChild(fragment);
safeCreateIcons();
}
+68 -3
View File
@@ -1,5 +1,5 @@
/* ObsiGate — Theme System
* 15 themes x 2 modes (dark/light)
* 16 themes x 2 modes (dark/light)
* Persisted in localStorage as 'obsigate-theme'
* Usage: import { initThemes, applyTheme, getCurrentTheme, THEMES } from './themes.js'
*/
@@ -45,6 +45,71 @@ var THEMES = {
}
},
// ── Thème Spark ──────────────────────────────────────────────────────
// Palette source : console Spark (agent.dracodev.net), app/console/console.css
// (variables --p-*). Mapping Spark -> ObsiGate, identique en structure pour
// dark et light ; seules les valeurs changent (sauf accent/ok/warn/err).
// Fidèle (valeur Spark directe) :
// fond -> --bg-primary, --surface2 ; surface -> --surface ;
// chip -> --bg-secondary, --tag-bg ; chip-survol -> --bg-hover ;
// bord -> --border ; chip-bord -> --border-md, --scrollbar, --resize-handle ;
// texte -> --text-primary, --text ; doux -> --text-secondary, --text-2 ;
// faint -> --text-muted, --text-3 ; champ -> --search-bg ;
// code -> --code-bg ; accent -> --accent, --accent-card, --purple, --ai ;
// lien -> --accent-text (variante lisible de l'accent sur fond teinté) ;
// ok -> --accent-green, --success, --green ;
// danger -> --danger ; danger-fond -> --danger-bg ;
// pastilles ok (fond) -> --success-bg, --green-bg ;
// pastilles ok (texte) -> --ok ; ambre -> --warn ; rouge -> --err.
// Approximations documentées (ObsiGate a plus de variables que Spark,
// ce ne sont PAS des valeurs Spark) :
// --bg-sidebar <- fond (Spark n'a pas de token sidebar dédié) ;
// --accent-bg <- agent (surface teintée de Spark, pas de teinte lavande) ;
// --surface3 <- agent ; --purple-bg <- appro-fond (fond teinté le plus
// proche, la palette Spark n'a pas de teinte lavande) ;
// --overlay-bg <- convention ObsiGate (rgba noir 0.3 clair / 0.5 sombre,
// aucun token voile dans la palette Spark).
// Non reportés (aucune variable ObsiGate correspondante dans les 39) :
// bulle utilisateur/envoi #2f6fed, titres approbation #9a6a1c / #e8bc7a.
// Une seule entrée 'spark' : dark et light sont ses deux modes, jamais
// deux thèmes séparés. Modes high-contrast/sepia générés comme pour les
// autres thèmes (à partir de dark/light).
'spark': {
name: 'Spark', desc: 'Palette de la console Spark',
modes: {
dark: {
'--bg-primary': '#17181a', '--bg-secondary': '#26272b', '--bg-sidebar': '#17181a',
'--bg-hover': '#2e3036', '--border': '#33353a', '--text-primary': '#eceeeb',
'--text-secondary': '#a6aba0', '--text-muted': '#83887e', '--accent': '#7c5cd6',
'--accent-green': '#2ea36b', '--tag-bg': '#26272b', '--tag-text': '#7c5cd6',
'--code-bg': '#2c2e33', '--search-bg': '#1f2023', '--scrollbar': '#3a3c41',
'--resize-handle': '#3a3c41', '--overlay-bg': 'rgba(0,0,0,0.5)', '--danger': '#e8a08d',
'--danger-bg': '#40241f', '--success': '#2ea36b', '--success-bg': '#173d29',
'--accent-card': '#7c5cd6', '--accent-bg': '#26272b', '--accent-text': '#b39df5',
'--green': '#2ea36b', '--green-bg': '#173d29', '--purple': '#7c5cd6',
'--purple-bg': '#2a2118', '--surface': '#1f2023', '--surface2': '#17181a',
'--surface3': '#26272b', '--border-md': '#3a3c41', '--text': '#eceeeb',
'--text-2': '#a6aba0', '--text-3': '#83887e', '--ok': '#7fd6a8',
'--warn': '#e8a13c', '--ai': '#7c5cd6', '--err': '#cf4b32'
},
light: {
'--bg-primary': '#fbfbfa', '--bg-secondary': '#f4f5f1', '--bg-sidebar': '#fbfbfa',
'--bg-hover': '#e9ebe4', '--border': '#e4e5e0', '--text-primary': '#242624',
'--text-secondary': '#6c7268', '--text-muted': '#8a8f84', '--accent': '#7c5cd6',
'--accent-green': '#2ea36b', '--tag-bg': '#f4f5f1', '--tag-text': '#7c5cd6',
'--code-bg': '#e8eae3', '--search-bg': '#ffffff', '--scrollbar': '#d6d8d1',
'--resize-handle': '#d6d8d1', '--overlay-bg': 'rgba(0,0,0,0.3)', '--danger': '#a03a28',
'--danger-bg': '#f6e3de', '--success': '#2ea36b', '--success-bg': '#dff2e7',
'--accent-card': '#7c5cd6', '--accent-bg': '#eef0ea', '--accent-text': '#5b3df0',
'--green': '#2ea36b', '--green-bg': '#dff2e7', '--purple': '#7c5cd6',
'--purple-bg': '#fdf6ec', '--surface': '#ffffff', '--surface2': '#fbfbfa',
'--surface3': '#eef0ea', '--border-md': '#d6d8d1', '--text': '#242624',
'--text-2': '#6c7268', '--text-3': '#8a8f84', '--ok': '#1d7a4c',
'--warn': '#e8a13c', '--ai': '#7c5cd6', '--err': '#cf4b32'
}
}
},
'github': {
name: 'GitHub', desc: 'Contraste doux',
modes: {
@@ -523,7 +588,7 @@ var THEMES = {
};
// ── Theme Engine ─────────────────────────────────────────────────────
var _currentTheme = 'defaut-obsigate';
var _currentTheme = 'spark';
var _currentMode = 'dark';
var AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia'];
@@ -588,7 +653,7 @@ function applyTheme(themeKey, mode) {
root.style.setProperty(key, vars[key]);
});
// #180 — the spreadsheet grid follows the active theme: derive its tokens
// from the generic surfaces so every theme (15 built-in × 4 modes, custom
// from the generic surfaces so every theme (16 built-in × 4 modes, custom
// imports) gets a matching grid instead of the hardcoded dark/light pair.
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
+77 -3
View File
@@ -964,8 +964,8 @@ const FileOperations = {
if (type === 'file' && state.currentVault === vault && state.currentPath === path) {
await openFile(vault, nextPath);
} else if (type === 'directory' && state.currentVault === vault && state.currentPath && (state.currentPath === path || currentPath.startsWith(`${path}/`))) {
const suffix = state.currentPath === path ? '' : currentPath.slice(path.length);
} else if (type === 'directory' && state.currentVault === vault && state.currentPath && (state.currentPath === path || state.currentPath.startsWith(`${path}/`))) {
const suffix = state.currentPath === path ? '' : state.currentPath.slice(path.length);
state.currentPath = `${nextPath}${suffix}`;
await focusPathInSidebar(vault, state.currentPath, { alignToTop: false });
}
@@ -1051,7 +1051,7 @@ const FileOperations = {
this._closeModal(overlay);
await refreshSidebarTreePreservingState();
if (state.currentVault === vault && state.currentPath && currentPath.startsWith(path)) {
if (state.currentVault === vault && state.currentPath && state.currentPath.startsWith(path)) {
showWelcome();
}
} catch (err) {
@@ -1826,9 +1826,13 @@ export const ContextMenuManager = {
this._addSeparator();
this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, this._targetPath)); }, false);
this._addSeparator();
this._addItem('move', t('common.move'), () => this._moveItem(), isReadonly);
this._addItem('copy', t('common.duplicate'), () => this._duplicateItem(), isReadonly);
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly);
} else if (type === 'file') {
this._addItem('move', t('common.move'), () => this._moveItem(), isReadonly);
this._addItem('copy', t('common.duplicate'), () => this._duplicateItem(), isReadonly);
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly);
this._addSeparator();
@@ -1918,6 +1922,76 @@ export const ContextMenuManager = {
FileOperations.confirmDeleteFile(this._targetVault, this._targetPath);
},
// #BUG-117 — Déplacer / Dupliquer via le modal harmonisé promptTargetFolder
// (réutilisé de upload.js, opts pour titre/bouton). Le backend ne supporte
// que le même vault ; choix d'un autre vault = toast d'erreur.
async _pickDestination(title) {
const vault = this._targetVault;
const name = this._targetPath.split('/').pop();
const parent = this._targetPath.includes('/') ? this._targetPath.slice(0, this._targetPath.lastIndexOf('/')) : '';
const { promptTargetFolder } = await import('./upload.js');
// Destination pré-sélectionnée = emplacement actuel (sinon '' = racine,
// ce qui dupliquait/mouvait à la racine par défaut).
const picked = await promptTargetFolder(vault, parent, [], {
title: title + ' — ' + name,
summaryText: t('ui.move_summary', { name: name }),
confirmLabel: title,
});
if (!picked) return null;
if (picked.vault !== vault) {
showToast(t('toast.move_cross_vault'), 'error');
return null;
}
return picked;
},
async _moveItem() {
const vault = this._targetVault;
const path = this._targetPath;
const type = this._targetType;
let picked = null;
try {
picked = await this._pickDestination(t('common.move'));
if (!picked) return;
const result = await api(`/api/move/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_path: path, destination_dir: picked.targetDir }),
});
await refreshSidebarTreePreservingState();
const nextPath = result.new_path;
if (state.currentVault === vault && state.currentPath && (state.currentPath === path || (type === 'directory' && state.currentPath.startsWith(path + '/')))) {
if (type === 'file') {
await openFile(vault, nextPath);
} else {
state.currentPath = nextPath + state.currentPath.slice(path.length);
syncActiveFileTreeItem();
}
}
showToast(picked.targetDir ? t('toast.moved', { dir: picked.targetDir }) : t('toast.move_root'), 'success');
} catch (err) {
showToast(err.message || t('common.error'), 'error');
}
},
async _duplicateItem() {
const vault = this._targetVault;
const path = this._targetPath;
try {
const picked = await this._pickDestination(t('common.duplicate'));
if (!picked) return;
const result = await api(`/api/duplicate/${encodeURIComponent(vault)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_path: path, destination_dir: picked.targetDir }),
});
await refreshSidebarTreePreservingState();
showToast(t('toast.duplicated', { name: result.new_path.split('/').pop() }), 'success');
} catch (err) {
showToast(err.message || t('common.error'), 'error');
}
},
_copyPath(path) {
// Try modern clipboard API first, fall back to execCommand for non-secure contexts
if (navigator.clipboard && navigator.clipboard.writeText) {
+4 -4
View File
@@ -232,7 +232,7 @@ export async function handleVaultDropUpload(vault, targetDir, items) {
* @param {Array} items - Items to upload
* @returns {Promise<{ vault: string, targetDir: string }|null>} Selected target or null if cancelled.
*/
export async function promptTargetFolder(initialVault, initialDir = '', items = []) {
export async function promptTargetFolder(initialVault, initialDir = '', items = [], opts = {}) {
let vaultsList = [];
try {
const vRes = await api('/api/vaults');
@@ -316,12 +316,12 @@ export async function promptTargetFolder(initialVault, initialDir = '', items =
overlay.innerHTML = `
<div class="obsigate-modal" style="min-width: 460px; max-width: 560px;">
<div class="obsigate-modal-header">
<span class="obsigate-modal-title">📁 ${escapeHtml(t('upload.choose_target_folder') || "Destination de l'importation")}</span>
<span class="obsigate-modal-title">📁 ${escapeHtml(opts.title || t('upload.choose_target_folder') || "Destination de l'importation")}</span>
<button class="modal-close modal-btn" type="button" aria-label="Fermer" style="padding: 4px 8px; line-height: 1;">×</button>
</div>
<div class="obsigate-modal-body">
<p style="margin: 0 0 16px 0; color: var(--text-muted); font-size: 0.9rem;">
${escapeHtml(t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`)}
${opts.summaryText ? escapeHtml(opts.summaryText) : escapeHtml(t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`)}
</p>
<div class="modal-form-group">
@@ -352,7 +352,7 @@ export async function promptTargetFolder(initialVault, initialDir = '', items =
</div>
<div class="obsigate-modal-footer">
<button type="button" class="modal-btn modal-cancel-btn">${escapeHtml(t('button.cancel') || 'Annuler')}</button>
<button type="button" class="modal-btn primary modal-confirm-btn">${escapeHtml(t('upload.import_button') || 'Importer')}</button>
<button type="button" class="modal-btn primary modal-confirm-btn">${escapeHtml(opts.confirmLabel || t('upload.import_button') || 'Importer')}</button>
</div>
</div>
`;
+116 -1
View File
@@ -358,6 +358,7 @@
"common.error": "Error",
"common.loading": "Loading...",
"common.move": "Move",
"common.duplicate": "Duplicate",
"common.never": "Never",
"common.next": "Next",
"common.no": "No",
@@ -751,6 +752,13 @@
"upload.custom_folder": "Or enter a new subfolder path...",
"upload.import_summary": "Items to import: {files} file(s), {dirs} folder(s)",
"upload.import_button": "Import",
"draco.links.add": "Link",
"draco.links.labelPlaceholder": "Label (optional)",
"draco.links.openInTikTak": "Open in TikTak",
"draco.links.projected": "Projected TikTak task",
"draco.links.remove": "Remove link",
"draco.links.title": "Links",
"draco.links.urlPlaceholder": "Paste a deep link (Flowdeck, TikTak, Gitea, ObsiGate)…",
"editor.cancel": "Cancel",
"editor.close": "Close",
"editor.delete_error": "Delete error",
@@ -1834,6 +1842,11 @@
"toast.no_open_file": "No file open",
"toast.not_found_tree": "Item not found in tree",
"toast.path_copied": "Path copied: {path}",
"ui.move_summary": "Item to move: {name}",
"toast.moved": "Moved to: {dir}",
"toast.move_root": "Moved to vault root",
"toast.duplicated": "Duplicated: {name}",
"toast.move_cross_vault": "Cross-vault move/duplicate not supported",
"toast.reindex_done": "Reindexing complete",
"toast.reindex_started": "Reindexing...",
"toast.rename_error": "Rename error",
@@ -2653,5 +2666,107 @@
"guide105.dl_md_title": "Download this guide as Markdown",
"guide105.dl_pdf_title": "Download this guide as PDF",
"guide105.export_title": "ObsiGate User Guide",
"guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application."
"guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application.",
"coffres.acces": "Access and consumers",
"coffres.actions": "Actions",
"coffres.active": "Active",
"coffres.confirm_dek_rotate": "Rotate this vault's DEK? Secrets will be re-encrypted, with no change for consumers.",
"coffres.confirm_delete": "Delete this secret? The following grants will break:",
"coffres.confirm_revoke_consumer": "Revoke this consumer? All of its distributions will be denied.",
"coffres.confirm_revoke_octroi": "Revoke this grant? Access will be denied on the next call.",
"coffres.consumer_no_distribution": "No distribution in the recent log.",
"coffres.consumers": "Consumers",
"coffres.copied": "Copied.",
"coffres.copy_failed": "Copy failed.",
"coffres.copy_generated": "Copy the generated value",
"coffres.copy_reference": "Copy",
"coffres.create_coffre_title": "New vault",
"coffres.create_octroi": "Create a grant",
"coffres.create_secret_title": "New secret",
"coffres.created": "Created",
"coffres.dek_rotate": "Rotate DEK",
"coffres.delete": "Delete",
"coffres.deleted": "Secret deleted.",
"coffres.description": "Description",
"coffres.detail": "Details",
"coffres.disabled": "Secret vaults are disabled on this instance (OBSIGATE_SECRETS_ENABLED).",
"coffres.expiration": "Expiration",
"coffres.expired": "Expired",
"coffres.expires": "Expires",
"coffres.expiring_soon": "Expiring soon",
"coffres.generate": "Generate",
"coffres.generate_unavailable": "Generation is unavailable in this browser.",
"coffres.grant_all_coffre": "Whole vault",
"coffres.journal": "Access log",
"coffres.kek_absente": "Master key missing: metadata is visible, but no secret can be created or distributed.",
"coffres.kek_generate": "Generate a KEK",
"coffres.kek_generate_hint": "The generated KEK is returned only once and is never stored by ObsiGate: back it up outside the repository immediately.",
"coffres.kek_new": "New KEK",
"coffres.kek_ok": "Master key configured.",
"coffres.kek_once_hint": "Copy it now and back it up outside the repository: it will never be shown again.",
"coffres.kek_rotate": "Rotate KEK",
"coffres.kek_rotated": "KEK rotated. Remember to replace the KEK in the environment or KEK file.",
"coffres.kek_rotation_hint": "After rotation, replace the KEK in the environment or KEK file, otherwise distributions will fail.",
"coffres.last_use": "Last use",
"coffres.loading": "Loading vaults…",
"coffres.manual_copy": "One-time manual copy",
"coffres.manual_copy_admin_only": "Admin only (no session-based distribution endpoint exists yet on the backend).",
"coffres.manual_copy_confirm": "A temporary consumer will be created, the value will be copied to the clipboard without being shown, then the consumer will be revoked immediately. The distribution will be audited.",
"coffres.modified": "Modified",
"coffres.name": "Name",
"coffres.name_hint": "Lowercase, digits, - or _ ; 3 to 128 characters (e.g. gitea-token).",
"coffres.name_required": "A name is required.",
"coffres.never_in_note": "Never paste the value into the note.",
"coffres.new_coffre": "New vault",
"coffres.new_consumer": "New consumer",
"coffres.new_secret": "New secret",
"coffres.no_coffre": "No visible vault.",
"coffres.no_secrets": "No secret in this vault.",
"coffres.note": "Note",
"coffres.octroi_created": "Grant created.",
"coffres.octroi_default_90": "Empty = default expiration (90 days).",
"coffres.octroi_no_expiration": "No expiration",
"coffres.octroi_revoked": "Grant revoked.",
"coffres.octrois": "Grants",
"coffres.perimetre": "Scope",
"coffres.propose_octroi": "Propose a grant (optional)",
"coffres.reference": "Reference",
"coffres.revoke": "Revoke",
"coffres.rotated": "Secret rotated: the new version is active.",
"coffres.rotation": "Rotate",
"coffres.rotation_title": "Rotate secret",
"coffres.save": "Save",
"coffres.saved": "Secret saved. The value will no longer be shown.",
"coffres.search_placeholder": "Filter secrets…",
"coffres.secret": "Secret",
"coffres.secret_saved_octroi_failed": "Secret saved, but the grant failed",
"coffres.secrets": "Secrets",
"coffres.select_secret": "Select a secret to see its metadata.",
"coffres.service": "Service",
"coffres.show_before_save": "Show before saving",
"coffres.slug": "Slug",
"coffres.slug_hint": "Lowercase, digits, - or _ ; 3 to 64 characters.",
"coffres.status": "Status",
"coffres.subtitle": "Secret vaults — metadata only, a value is never shown again after it is saved.",
"coffres.target_id": "Target id",
"coffres.target_id_required": "A target id is required.",
"coffres.target_type": "Target type",
"coffres.title": "Secrets",
"coffres.token_once": "Token (shown only once)",
"coffres.token_once_hint": "Copy it now: it will never be shown again.",
"coffres.type": "Type",
"coffres.value": "Value",
"coffres.value_required": "A value is required.",
"coffres.version": "Version",
"coffres.import_existing": "Import existing",
"coffres.import_title": "Import existing keys",
"coffres.import_has_value": "Value present",
"coffres.import_no_value": "No value",
"coffres.import_already_exists": "Already in the vault",
"coffres.import_migrated": "Migrated",
"coffres.import_warning": "The AI and tool keys from data/api_keys.json will be copied into the instance vault “ObsiGate interne”. Import deletes nothing. Clearing the plaintext is a separate step, to confirm explicitly — note: the current readers (AI, tools) still read api_keys.json, so clearing deprives them of the key until their reader is wired to the vault.",
"coffres.import_done": "Import finished",
"coffres.import_purge": "Delete plaintext",
"coffres.import_purge_confirm": "Permanently delete the following migrated keys from plaintext? This cannot be undone without restoring the file:",
"coffres.import_purged": "Plaintext deleted"
}
+116 -1
View File
@@ -358,6 +358,7 @@
"common.error": "Erreur",
"common.loading": "Chargement...",
"common.move": "Déplacer",
"common.duplicate": "Dupliquer",
"common.never": "Jamais",
"common.next": "Suivant",
"common.no": "Non",
@@ -751,6 +752,13 @@
"upload.custom_folder": "Ou saisir un nouveau sous-dossier...",
"upload.import_summary": "Éléments à importer : {files} fichier(s), {dirs} dossier(s)",
"upload.import_button": "Importer",
"draco.links.add": "Lier",
"draco.links.labelPlaceholder": "Libellé (optionnel)",
"draco.links.openInTikTak": "Ouvrir dans TikTak",
"draco.links.projected": "Tâche TikTak projetée",
"draco.links.remove": "Retirer le lien",
"draco.links.title": "Liens",
"draco.links.urlPlaceholder": "Coller une URL profonde (Flowdeck, TikTak, Gitea, ObsiGate)…",
"editor.cancel": "Annuler",
"editor.close": "Fermer",
"editor.delete_error": "Erreur de suppression",
@@ -1834,6 +1842,11 @@
"toast.no_open_file": "Aucun fichier ouvert",
"toast.not_found_tree": "Élément introuvable dans l'arborescence",
"toast.path_copied": "Chemin copié : {path}",
"ui.move_summary": "Élément à déplacer : {name}",
"toast.moved": "Déplacé vers : {dir}",
"toast.move_root": "Déplacé vers la racine de la voute",
"toast.duplicated": "Dupliqué : {name}",
"toast.move_cross_vault": "Déplacement/duplication entre voutes non supporté",
"toast.reindex_done": "Réindexation terminée",
"toast.reindex_started": "Réindexation en cours...",
"toast.rename_error": "Erreur lors du renommage",
@@ -2653,5 +2666,107 @@
"guide105.dl_md_title": "Télécharger ce guide en Markdown",
"guide105.dl_pdf_title": "Télécharger ce guide en PDF",
"guide105.export_title": "Guide d'utilisation ObsiGate",
"guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application."
"guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application.",
"coffres.acces": "Accès et consommateurs",
"coffres.actions": "Actions",
"coffres.active": "Actif",
"coffres.confirm_dek_rotate": "Pivoter la DEK de ce coffre ? Les secrets seront re-chiffrés, sans changement pour les consommateurs.",
"coffres.confirm_delete": "Supprimer ce secret ? Les octrois suivants seront cassés :",
"coffres.confirm_revoke_consumer": "Révoquer ce consommateur ? Toutes ses distributions seront refusées.",
"coffres.confirm_revoke_octroi": "Révoquer cet octroi ? L'accès sera refusé dès le prochain appel.",
"coffres.consumer_no_distribution": "Aucune distribution dans le journal récent.",
"coffres.consumers": "Consommateurs",
"coffres.copied": "Copié.",
"coffres.copy_failed": "Copie impossible.",
"coffres.copy_generated": "Copier la valeur générée",
"coffres.copy_reference": "Copier",
"coffres.create_coffre_title": "Nouveau coffre",
"coffres.create_octroi": "Créer un octroi",
"coffres.create_secret_title": "Nouveau secret",
"coffres.created": "Créé",
"coffres.dek_rotate": "Rotation DEK",
"coffres.delete": "Supprimer",
"coffres.deleted": "Secret supprimé.",
"coffres.description": "Description",
"coffres.detail": "Détail",
"coffres.disabled": "Les coffres de secrets sont désactivés sur cette instance (OBSIGATE_SECRETS_ENABLED).",
"coffres.expiration": "Expiration",
"coffres.expired": "Expiré",
"coffres.expires": "Expire",
"coffres.expiring_soon": "Expire bientôt",
"coffres.generate": "Générer",
"coffres.generate_unavailable": "Génération indisponible dans ce navigateur.",
"coffres.grant_all_coffre": "Tout le coffre",
"coffres.journal": "Journal d'accès",
"coffres.kek_absente": "Clé maître absente : les métadonnées sont visibles, mais aucun secret ne peut être créé ou distribué.",
"coffres.kek_generate": "Générer une KEK",
"coffres.kek_generate_hint": "La KEK générée n'est rendue qu'une fois et n'est jamais stockée par ObsiGate : sauvegardez-la hors dépôt immédiatement.",
"coffres.kek_new": "Nouvelle KEK",
"coffres.kek_ok": "Clé maître configurée.",
"coffres.kek_once_hint": "Copiez-la maintenant et sauvegardez-la hors dépôt : elle ne sera plus jamais affichée.",
"coffres.kek_rotate": "Rotation KEK",
"coffres.kek_rotated": "KEK pivotée. Pensez à remplacer la KEK dans l'environnement ou le fichier KEK.",
"coffres.kek_rotation_hint": "Après la rotation, remplacez la KEK dans l'environnement ou le fichier KEK, sinon les distributions échoueront.",
"coffres.last_use": "Dernière utilisation",
"coffres.loading": "Chargement des coffres…",
"coffres.manual_copy": "Copie manuelle à usage unique",
"coffres.manual_copy_admin_only": "Réservée aux administrateurs (aucun endpoint de distribution par session n'existe encore côté backend).",
"coffres.manual_copy_confirm": "Un consommateur temporaire sera créé, la valeur sera copiée au presse-papiers sans être affichée, puis le consommateur sera révoqué immédiatement. La distribution sera auditée.",
"coffres.modified": "Modifié",
"coffres.name": "Nom",
"coffres.name_hint": "Minuscules, chiffres, - ou _ ; 3 à 128 caractères (ex. gitea-token).",
"coffres.name_required": "Un nom est requis.",
"coffres.never_in_note": "Ne collez jamais la valeur dans la note.",
"coffres.new_coffre": "Nouveau coffre",
"coffres.new_consumer": "Nouveau consommateur",
"coffres.new_secret": "Nouveau secret",
"coffres.no_coffre": "Aucun coffre visible.",
"coffres.no_secrets": "Aucun secret dans ce coffre.",
"coffres.note": "Note",
"coffres.octroi_created": "Octroi créé.",
"coffres.octroi_default_90": "Vide = expiration par défaut (90 jours).",
"coffres.octroi_no_expiration": "Sans expiration",
"coffres.octroi_revoked": "Octroi révoqué.",
"coffres.octrois": "Octrois",
"coffres.perimetre": "Périmètre",
"coffres.propose_octroi": "Proposer un octroi (optionnel)",
"coffres.reference": "Référence",
"coffres.revoke": "Révoquer",
"coffres.rotated": "Secret pivoté : la nouvelle version est active.",
"coffres.rotation": "Rotation",
"coffres.rotation_title": "Rotation du secret",
"coffres.save": "Enregistrer",
"coffres.saved": "Secret enregistré. La valeur ne sera plus affichée.",
"coffres.search_placeholder": "Filtrer les secrets…",
"coffres.secret": "Secret",
"coffres.secret_saved_octroi_failed": "Secret enregistré, mais l'octroi a échoué",
"coffres.secrets": "Secrets",
"coffres.select_secret": "Sélectionnez un secret pour voir ses métadonnées.",
"coffres.service": "Service",
"coffres.show_before_save": "Afficher avant l'enregistrement",
"coffres.slug": "Identifiant (slug)",
"coffres.slug_hint": "Minuscules, chiffres, - ou _ ; 3 à 64 caractères.",
"coffres.status": "Statut",
"coffres.subtitle": "Coffres de secrets — métadonnées seules, aucune valeur n'est jamais affichée après l'enregistrement.",
"coffres.target_id": "Identifiant de la cible",
"coffres.target_id_required": "L'identifiant de la cible est requis.",
"coffres.target_type": "Type de cible",
"coffres.title": "Secrets",
"coffres.token_once": "Jeton (affiché une seule fois)",
"coffres.token_once_hint": "Copiez-le maintenant : il ne sera plus jamais affiché.",
"coffres.type": "Type",
"coffres.value": "Valeur",
"coffres.value_required": "Une valeur est requise.",
"coffres.version": "Version",
"coffres.import_existing": "Importer l'existant",
"coffres.import_title": "Importer les clés existantes",
"coffres.import_has_value": "Valeur présente",
"coffres.import_no_value": "Aucune valeur",
"coffres.import_already_exists": "Déjà dans le coffre",
"coffres.import_migrated": "Migré",
"coffres.import_warning": "Les clés IA et clés d'outils de data/api_keys.json seront copiées dans le coffre d'instance « ObsiGate interne ». L'import ne supprime rien. La purge du clair est une étape séparée, à confirmer explicitement — attention : les lecteurs actuels (IA, outils) lisent encore api_keys.json, la purge les prive donc de la clé tant que leur lecteur n'est pas branché sur le coffre.",
"coffres.import_done": "Import terminé",
"coffres.import_purge": "Supprimer le clair",
"coffres.import_purge_confirm": "Supprimer définitivement du clair les clés migrées suivantes ? Cette action est irréversible sans restauration du fichier :",
"coffres.import_purged": "Clair supprimé"
}
+35
View File
@@ -0,0 +1,35 @@
/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter
* toute collision avec les styles de l'hôte. Couleurs neutres héritées
* autant que possible : la page reste lisible en thème clair et sombre. */
.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; }
.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; }
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
.rp-input {
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit;
}
.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
.rp-section { margin-bottom: 28px; }
.rp-head {
display: flex; align-items: baseline; gap: 10px;
border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px;
}
.rp-head h2 { font-size: 1.05rem; margin: 0; }
.rp-count {
font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff;
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
}
.rp-count:empty { display: none; }
.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; }
.rp-state { opacity: .75; margin: 8px 0; }
.rp-error { opacity: 1; }
.rp-item {
display: block; padding: 8px 10px; border-radius: 8px;
text-decoration: none; color: inherit;
}
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
.rp-item:focus-visible { outline: 2px solid #3b82f6; }
.rp-title { display: block; font-weight: 600; }
.rp-context { display: block; font-size: .8rem; opacity: .65; }
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rechercher partout — ObsiGate</title>
<link rel="stylesheet" href="/static/recherche.css">
<style>
/* Navigation minimale (la barre complète vit dans index.html, la SPA). */
.rp-nav { display: flex; gap: 16px; padding: 12px 16px; font-size: .9rem; }
.rp-nav a { color: inherit; opacity: .8; }
</style>
</head>
<body>
<!-- Jetons {{URL_*}} remplacés par la route /recherche (même contrat
que le fragment de barre). Servie brute via /static, la page
retomberait sur les défauts prod (les jetons sont ignorés par
le JS) — jamais d'URL inventée. -->
<nav class="rp-nav">
<a href="{{URL_OBSIGATE}}">ObsiGate</a>
<a href="{{URL_FLOWDECK}}">Flowdeck</a>
<a href="{{URL_TIKTAK}}">TikTak</a>
<a href="{{URL_KANBANTE}}">Kanbante</a>
</nav>
<main class="rp-page" id="recherche" data-host="obsigate"
data-base-obsigate="{{URL_OBSIGATE}}"
data-base-flowdeck="{{URL_FLOWDECK}}"
data-base-tiktak="{{URL_TIKTAK}}"
data-base-kanbante="{{URL_KANBANTE}}">
<h1>Rechercher partout</h1>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value=""
placeholder="Rechercher partout…" autocomplete="off" autofocus>
</div>
<section class="rp-section" data-rp-section="obsigate">
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="flowdeck">
<div class="rp-head"><h2>Flowdeck — pages</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="tiktak">
<div class="rp-head"><h2>TikTak — tâches</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="kanbante">
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
</main>
<script src="/static/js/recherche.js"></script>
</body>
</html>
+130 -44
View File
@@ -11,58 +11,58 @@
/* ── Editor save dot state colors ── */
:root {
--ok: #34d399;
--warn: #fbbf24;
--ai: #8b5cf6;
--err: #f87171;
--ok: #7fd6a8;
--warn: #e8a13c;
--ai: #7c5cd6;
--err: #cf4b32;
}
/* ===== THEME — DARK (fallback default, no data-theme) ===== */
:root {
--bg-primary: #0f1117;
--bg-secondary: #161b22;
--bg-sidebar: #13161d;
--bg-hover: #1f2430;
--border: #21262d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #484f58;
--accent: #58a6ff;
--accent-green: #3fb950;
--tag-bg: #1f2a3a;
--tag-text: #79c0ff;
--code-bg: #161b22;
--search-bg: #21262d;
--scrollbar: #30363d;
--resize-handle: #30363d;
--bg-primary: #17181a;
--bg-secondary: #26272b;
--bg-sidebar: #17181a;
--bg-hover: #2e3036;
--border: #33353a;
--text-primary: #eceeeb;
--text-secondary: #a6aba0;
--text-muted: #83887e;
--accent: #7c5cd6;
--accent-green: #2ea36b;
--tag-bg: #26272b;
--tag-text: #7c5cd6;
--code-bg: #2c2e33;
--search-bg: #1f2023;
--scrollbar: #3a3c41;
--resize-handle: #3a3c41;
--overlay-bg: rgba(0, 0, 0, 0.5);
--danger: #ff7b72;
--danger-bg: #3d1a18;
--success: #3fb950;
--success-bg: #1a3d1f;
--accent-card: #58a6ff;
--accent-bg: #1f2a3a;
--accent-text: #79c0ff;
--green: #3fb950;
--green-bg: #1a3d1f;
--purple: #a371f7;
--purple-bg: #2d1b4e;
--surface: #161b22;
--surface2: #0f1117;
--surface3: #0d0f14;
--border-md: #30363d;
--text: #e6edf3;
--text-2: #8b949e;
--text-3: #484f58;
--danger: #e8a08d;
--danger-bg: #40241f;
--success: #2ea36b;
--success-bg: #173d29;
--accent-card: #7c5cd6;
--accent-bg: #26272b;
--accent-text: #b39df5;
--green: #2ea36b;
--green-bg: #173d29;
--purple: #7c5cd6;
--purple-bg: #2a2118;
--surface: #1f2023;
--surface2: #17181a;
--surface3: #26272b;
--border-md: #3a3c41;
--text: #eceeeb;
--text-2: #a6aba0;
--text-3: #83887e;
--mono: "JetBrains Mono", monospace;
--radius: 6px;
--radius-lg: 10px;
/* #154-A1 — data-grid tokens: the sheet reads slightly lighter than the
chrome (toolbar/panels), headers are clearly distinct from cells. */
--grid-bg: #161b22;
--grid-header-bg: #1f2430;
--grid-header-text: #e6edf3;
--grid-border: #3d444d;
--grid-bg: #1f2023;
--grid-header-bg: #2e3036;
--grid-header-text: #eceeeb;
--grid-border: #33353a;
--grid-zebra: rgba(255, 255, 255, 0.025);
}
@@ -7700,6 +7700,8 @@ body.popup-mode .content-area {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
box-sizing: border-box;
opacity: 0;
pointer-events: none;
transition: opacity 200ms ease;
@@ -7715,8 +7717,16 @@ body.popup-mode .content-area {
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
min-width: 400px;
max-width: 600px;
/* Correctif Phase 2 (modale de création de secret coupée en haut) :
sans max-height ni scroll interne, un formulaire long dépassait le
viewport. La modale est désormais bornée au viewport, en colonne
flex, avec seul le corps qui défile ; l'en-tête et le pied restent
toujours visibles. Sur petit écran, la largeur est bornée aussi. */
min-width: min(400px, calc(100vw - 32px));
max-width: min(600px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
display: flex;
flex-direction: column;
transform: scale(0.9);
transition: transform 200ms ease;
}
@@ -7731,6 +7741,7 @@ body.popup-mode .content-area {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.obsigate-modal-title {
@@ -7742,6 +7753,8 @@ body.popup-mode .content-area {
.obsigate-modal-body {
padding: 20px;
overflow-y: auto;
min-height: 0;
}
.obsigate-modal-footer {
@@ -7750,6 +7763,7 @@ body.popup-mode .content-area {
display: flex;
gap: 10px;
justify-content: flex-end;
flex-shrink: 0;
}
.modal-input {
@@ -14292,3 +14306,75 @@ textarea.file-chat-input {
/* — Barre d'apps commune dracodev + section « Liens » des notes
(Fusion #215, snippet figé dracodev-commons v0.1.0) — */
body.has-draco-bar .app-container { height: calc(100vh - 60px); }
@media (max-width: 900px) {
body.has-draco-bar .app-container { height: calc(100vh - 52px); }
}
.draco-liens {
margin: 28px 0 8px; padding: 16px 18px;
border: 1px solid var(--border); border-radius: 12px;
background: var(--bg-secondary);
}
.draco-liens-title { margin: 0 0 10px; font-size: 15px; color: var(--text-primary); }
.draco-liens-projete {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
background: var(--bg-hover);
}
.draco-liens-badge { font-weight: 700; color: #e8762c; font-size: 13px; }
.draco-liens-ouvrir { text-decoration: none; }
.draco-liens-list { list-style: none; margin: 0 0 12px; padding: 0; }
.draco-liens-item { display: flex; align-items: center; gap: 4px; padding: 4px 0; }
.draco-ref-dot {
display: inline-block; width: 9px; height: 9px; border-radius: 50%;
margin-right: 7px; flex: none;
}
.draco-liens-item a { color: var(--text-primary); }
.draco-liens-del {
margin-left: 8px; border: 0; background: transparent; cursor: pointer;
color: var(--text-secondary); font-size: 12px;
}
.draco-liens-del:hover { color: var(--err); }
.draco-liens-form { display: flex; gap: 6px; flex-wrap: wrap; }
.draco-liens-form input {
flex: 1 1 200px; min-width: 0; padding: 7px 10px; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg-primary);
color: var(--text-primary); font: inherit; font-size: 13.5px;
}
.draco-liens-erreur { color: var(--err); font-size: 13px; margin: 8px 0 0; }
/* ---------------------------------------------------------------------------
* Coffre Secrets (Phase 1c, issue #19) — vue Secrets + nœud sidebar.
* Réutilise les variables de thème existantes ; aucune valeur de secret
* n'est jamais affichée par ces styles (colonne valeur = •••••• en texte).
* --------------------------------------------------------------------------- */
.coffres-view { padding: 16px 20px; max-width: 1100px; }
.coffres-subtitle { color: var(--text-muted); margin: 0 0 12px; }
.coffres-banner { padding: 10px 12px; margin: 12px 0; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.coffres-banner.warn { border-color: var(--warning, #f59e0b); }
.coffres-chips { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
.coffres-chip { white-space: nowrap; }
.coffres-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
.coffres-actions .modal-input { flex: 1 1 220px; }
.coffres-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
.coffres-table th, .coffres-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border, #21262d); vertical-align: top; }
.coffres-table tbody tr { cursor: pointer; }
.coffres-table tbody tr:hover { background: var(--bg-hover, #1f2430); }
.coffres-detail { margin-top: 16px; padding: 14px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; }
.coffres-dl { display: grid; grid-template-columns: 160px 1fr; gap: 4px 12px; margin: 12px 0; }
.coffres-dl dt { color: var(--text-muted); }
.coffres-dl dd { margin: 0; overflow-wrap: anywhere; }
.coffres-line { padding: 6px 0; border-bottom: 1px solid var(--border, #21262d); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: space-between; }
.coffres-empty { color: var(--text-muted); }
.coffres-alerts { margin: 12px 0; }
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; }
.coffres-icon-lock { display: inline-flex; align-items: center; line-height: 1; }
.coffres-icon-lock svg { width: 16px; height: 16px; flex: none; }
.coffres-alert-dot { color: var(--warning, #f59e0b); font-weight: 700; margin-left: 4px; }
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
.coffres-context-item:hover { background: var(--bg-hover, #1f2430); }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.68.2",
"version": "2.81.3",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* ObsiGate — tests de la logique pure de la page « Rechercher partout »
* (fusion #68), sans DOM ni jsdom : résolution des bases, liens
* profonds et normalisation des 4 formats d'API.
*
* Le fichier frontend/js/recherche.js est chargé en ESM (frontend/js
* est "type": "module") ; ses helpers sont exposés via
* globalThis.RecherchePartout. Usage : node tests/frontend/recherche.test.mjs
*/
import { strict as assert } from "node:assert";
import { fileURLToPath, pathToFileURL } from "node:url";
import path from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const JS_FILE = path.resolve(__dirname, "..", "..", "frontend", "js", "recherche.js");
await import(pathToFileURL(JS_FILE).href);
const rp = globalThis.RecherchePartout;
assert.ok(rp, "RecherchePartout exposé");
let n = 0;
const ok = (cond, label) => {
assert.ok(cond, label);
n += 1;
};
ok(rp.isDevHost("og-dev.dracodev.net") === true, "dev reconnu");
ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté");
const b = rp.resolveBases({
host: "og-dev.dracodev.net",
explicit: { tiktak: "https://tiktak-perso.example/" },
barUrls: {},
tabHrefs: {},
});
ok(b.tiktak === "https://tiktak-perso.example/", "explicite prioritaire");
ok(b.obsigate === "https://og-dev.dracodev.net/", "dev pour l'hôte courant");
ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href");
ok(
rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8",
"path ObsiGate",
);
const items = rp.normalize(
"obsigate",
{ results: [{ vault: "home-bruno", path: "Notes/Idée.md", title: "Idée", snippet: "extrait" }] },
"https://og-dev.dracodev.net/",
"idée",
);
ok(
items[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md",
"lien profond ObsiGate",
);
ok(
rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") ===
"https://tiktak-dev.dracodev.net/search?q=q",
"repli TikTak",
);
console.log(`recherche.test.mjs : ${n} assertions OK`);
+302
View File
@@ -0,0 +1,302 @@
"""Émission d'événements vers le journal du service d'agents (#220,
Fusion Lot 2 vague 5 — contrat ACC-0.2).
Un faux récepteur local capture les POST signés HMAC et les vérifie
comme le fait le service d'agents ; on prouve aussi l'inactivité sans
clé, la non-régression métier quand le récepteur est en panne, et le
garde-fou central : le watcher / l'indexation n'émettent JAMAIS
(seules les écritures explicites via l'API ou les outils le font).
"""
import asyncio
import hashlib
import hmac
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from types import SimpleNamespace
import pytest
CLE = "cle-obsigate-de-test"
class _Recepteur(BaseHTTPRequestHandler):
recus = []
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
corps = self.rfile.read(n)
type(self).recus.append(
{"headers": {k.lower(): v for k, v in self.headers.items()},
"corps": corps})
self.send_response(201)
self.end_headers()
def log_message(self, *a):
pass
def _demarrer(monkeypatch):
_Recepteur.recus = []
serveur = HTTPServer(("127.0.0.1", 0), _Recepteur)
port = serveur.server_address[1]
threading.Thread(target=serveur.serve_forever, daemon=True).start()
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
f"http://127.0.0.1:{port}/api/v1/evenements")
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
return serveur
def _attendre(types, delai=6.0):
"""Attend que chaque type demandé soit arrivé ; retourne
{type: enveloppe} — la signature HMAC de CHAQUE envoi est
revérifiée ici, comme le fait le service."""
vus = {}
fin = time.time() + delai
while time.time() < fin and not all(t in vus for t in types):
for recu in list(_Recepteur.recus):
env = json.loads(recu["corps"].decode("utf-8"))
sig = recu["headers"].get("x-draco-signature", "")
attendu = hmac.new(CLE.encode(), recu["corps"],
hashlib.sha256).hexdigest()
assert hmac.compare_digest(sig.lower(), attendu), \
"signature HMAC invalide"
vus.setdefault(env["type"], env)
if not all(t in vus for t in types):
time.sleep(0.05)
manquants = [t for t in types if t not in vus]
assert not manquants, f"événements jamais reçus : {manquants}"
return vus
# ── Enveloppe ────────────────────────────────────────────────────────
def test_enveloppe_et_signature(monkeypatch):
from backend import agent_journal
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
env, corps = agent_journal.construire_enveloppe(
agent_journal.TYPE_CREE, "TestVault", "Journal/nouvelle.md")
assert env["source_app"] == "obsigate"
assert env["domain"] == "pro" # repli : aucun domaine modélisé (Q-A)
assert env["actor"] == "user"
assert env["object"] == {
"ref": "fichier:TestVault/Journal/nouvelle.md",
"label": "nouvelle.md",
"url": "/#file=TestVault:Journal/nouvelle.md",
}
assert json.loads(corps.decode("utf-8")) == env
assert env["id"] and env["occurred_at"]
sig = hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
assert len(sig) == 64
def test_domaine_par_vault(monkeypatch):
from backend import agent_journal
monkeypatch.setenv("AGENT_EVENEMENTS_DOMAINES",
json.dumps({"TestVault": "famille"}))
assert agent_journal.domaine_du_vault("TestVault") == "famille"
assert agent_journal.domaine_du_vault("AutreVault") == "pro"
# ── Écritures REST (acteur « user ») ─────────────────────────────────
def test_cycle_de_vie_rest(client, monkeypatch):
serveur = _demarrer(monkeypatch)
try:
r = client.post("/api/file/TestVault", json={
"path": "Journal/nouvelle.md", "content": "# Nouvelle\n"})
assert r.status_code == 200
r = client.put("/api/file/TestVault/save",
params={"path": "Journal/nouvelle.md"},
json={"content": "# Nouvelle v2\n"})
assert r.status_code == 200
r = client.patch("/api/file/TestVault", json={
"path": "Journal/nouvelle.md", "new_name": "renommee.md"})
assert r.status_code == 200
r = client.post("/api/move/TestVault", json={
"source_path": "Journal/renommee.md", "destination_dir": "Projets"})
assert r.status_code == 200
r = client.delete("/api/file/TestVault",
params={"path": "Projets/renommee.md"})
assert r.status_code == 200
vus = _attendre([
"obsigate.note.created", "obsigate.note.edited",
"obsigate.note.moved", "obsigate.note.deleted"])
env = vus["obsigate.note.created"]
assert env["actor"] == "user"
assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md"
assert env["object"]["label"] == "nouvelle.md"
assert env["object"]["url"] == "/#file=TestVault:Journal/nouvelle.md"
env = vus["obsigate.note.edited"]
assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md"
# moved : émis deux fois (renommage puis déplacement). Les
# envois partent en fils d'arrière-plan — l'ordre d'arrivée
# n'est pas garanti, on compare l'ensemble des références.
refs_deplacees = {
json.loads(recu["corps"].decode("utf-8"))["object"]["ref"]
for recu in _Recepteur.recus
if json.loads(recu["corps"].decode("utf-8"))["type"]
== "obsigate.note.moved"}
assert refs_deplacees == {
"fichier:TestVault/Journal/renommee.md",
"fichier:TestVault/Projets/renommee.md"}
env = vus["obsigate.note.deleted"]
assert env["object"]["ref"] == "fichier:TestVault/Projets/renommee.md"
finally:
serveur.shutdown()
# ── Écritures par les outils / MCP (acteur « agent:mcp ») ───────────
@pytest.fixture
def vault_outils(tmp_path, monkeypatch):
"""Vault minimal pour la couche outils (patron test_document_tools)."""
import backend.indexer
vault_dir = tmp_path / "Vault"
vault_dir.mkdir()
monkeypatch.setitem(
backend.indexer.index, "Vault",
{"name": "Vault", "path": str(vault_dir), "config": {}})
return vault_dir
def test_outils_mcp_emettent_agent_mcp(vault_outils, monkeypatch):
from backend.tools.api import ToolContext, ToolMode, call_tool
serveur = _demarrer(monkeypatch)
try:
ctx = ToolContext(
user={"username": "tester", "role": "admin", "vaults": ["*"]},
mode=ToolMode.MCP, audit_enabled=False)
out = call_tool("create_file", ctx, {
"vault": "Vault", "path": "par-mcp.md", "content": "# MCP\n",
}, confirm=True)
assert out.ok
out = call_tool("edit_file", ctx, {
"vault": "Vault", "path": "par-mcp.md", "content": "# MCP v2\n",
}, confirm=True)
assert out.ok
out = call_tool("rename_file", ctx, {
"vault": "Vault", "path": "par-mcp.md", "new_name": "renomme-mcp.md",
}, confirm=True)
assert out.ok
out = call_tool("delete_file", ctx, {
"vault": "Vault", "path": "renomme-mcp.md",
}, confirm=True)
assert out.ok
vus = _attendre([
"obsigate.note.created", "obsigate.note.edited",
"obsigate.note.moved", "obsigate.note.deleted"])
for env in vus.values():
assert env["actor"] == "agent:mcp"
assert vus["obsigate.note.created"]["object"]["ref"] == \
"fichier:Vault/par-mcp.md"
assert vus["obsigate.note.moved"]["object"]["ref"] == \
"fichier:Vault/renomme-mcp.md"
finally:
serveur.shutdown()
# ── Inactivité sans clé ──────────────────────────────────────────────
def test_inactif_sans_cle(client, monkeypatch):
serveur = _demarrer(monkeypatch)
monkeypatch.delenv("AGENT_EVENEMENTS_CLE")
try:
from backend import agent_journal
assert agent_journal.actif() is False
assert agent_journal.emettre(
agent_journal.TYPE_CREE, "TestVault", "x.md") is False
r = client.post("/api/file/TestVault", json={
"path": "sans-journal.md", "content": "# Rien\n"})
assert r.status_code == 200
time.sleep(0.6)
assert _Recepteur.recus == []
finally:
serveur.shutdown()
# ── Garde-fou : watcher et indexation n'émettent jamais ─────────────
def test_watcher_et_indexation_sans_evenement(client, test_vault_dir,
monkeypatch):
"""Une écriture disque externe (éditeur, synchro de fichiers…)
traitée par le watcher puis l'indexeur ne produit AUCUN
événement : seuls les appels explicites à l'API émettent."""
from pathlib import Path
appels = []
monkeypatch.setattr(
"backend.agent_journal.emettre",
lambda *a, **k: appels.append((a, k)) or False)
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
cible = Path(test_vault_dir) / "externe.md"
cible.write_text("# Écrit hors API\n", encoding="utf-8")
# Chemin watcher : les gestionnaires d'événements filesystem.
from backend.watcher import VaultEventHandler
boucle = asyncio.new_event_loop()
try:
file_attente: asyncio.Queue = asyncio.Queue()
handler = VaultEventHandler("TestVault", file_attente, boucle)
evt = SimpleNamespace(is_directory=False, src_path=str(cible),
dest_path=None)
handler.on_created(evt)
handler.on_modified(evt)
# Chemin indexation : la même fonction que le watcher appelle
# après debounce pour intégrer le fichier à l'index.
from backend.indexer import update_single_file
boucle.run_until_complete(update_single_file("TestVault", "externe.md"))
finally:
boucle.close()
assert appels == []
# ── Panne du récepteur : le métier n'est jamais affecté ─────────────
def test_recepteur_en_panne_ne_casse_rien(client, monkeypatch):
# Port fermé : connexion refusée dans le fil d'arrière-plan.
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
"http://127.0.0.1:59999/api/v1/evenements")
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
r = client.post("/api/file/TestVault", json={
"path": "malgre-panne.md", "content": "# OK\n"})
assert r.status_code == 200
r = client.put("/api/file/TestVault/save",
params={"path": "malgre-panne.md"},
json={"content": "# OK v2\n"})
assert r.status_code == 200
r = client.get("/api/file/TestVault", params={"path": "malgre-panne.md"})
assert r.status_code == 200
assert "OK v2" in r.json()["html"] # le GET renvoie le Markdown rendu
+71 -1
View File
@@ -340,6 +340,42 @@ class TestDirectoryCRUD:
data = resp.json()
assert data["success"] is True
def test_delete_directory_subtree_clears_index(self, client):
"""BUG-113 : suppression incrémentale — aucun fantôme dans paths ni
dans l'index inversé, sans re-scan complet de la vault."""
# Create files inside the directory
for name in ("alpha.md", "nested/beta.md"):
r = client.post("/api/file/TestVault", json={
"path": f"Projets/{name}",
"content": f"contenu unique {name}",
})
assert r.status_code == 200
# Indexed before deletion
paths_before = client.get("/api/vault/TestVault/paths").json()["results"]
assert any(p["path"] == "Projets/alpha.md" for p in paths_before)
hit = client.get("/api/search", params={"q": "alpha", "vault": "TestVault"}).json()
results = hit.get("results", hit if isinstance(hit, list) else [])
assert any(r["path"] == "Projets/alpha.md" for r in results)
# Delete the whole directory
resp = client.delete("/api/directory/TestVault", params={"path": "Projets"})
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["deleted_count"] >= 2
# No ghost in flat paths
paths_after = client.get("/api/vault/TestVault/paths").json()["results"]
ghost_dirs = [p["path"] for p in paths_after if p["path"].startswith("Projets")]
assert ghost_dirs == [], f"fantômes dans paths : {ghost_dirs}"
# No ghost in inverted index (full-text search)
hit = client.get("/api/search", params={"q": "alpha", "vault": "TestVault"}).json()
results = hit.get("results", hit if isinstance(hit, list) else [])
assert all(r["path"] != "Projets/alpha.md" for r in results)
assert all(r["path"] != "Projets/nested/beta.md" for r in results)
# ═══════════════════════════════════════════════════════════════════
# Search & Tags (supplement existing tests)
@@ -966,4 +1002,38 @@ class TestStaticCaching:
def test_static_asset_revalidates(self, client):
resp = client.get("/static/style.css")
assert resp.status_code == 200
assert "no-cache" in resp.headers.get("cache-control", "")
assert "no-cache" in resp.headers.get("cache-control", "")
class TestDuplicateApi:
"""BUG-117 : endpoint POST /api/duplicate — copie fichiers/répertoires."""
def test_duplicate_file_and_collision(self, client):
r = client.post("/api/duplicate/TestVault", json={"source_path": "note1.md", "destination_dir": ""})
assert r.status_code == 200
assert r.json()["new_path"] == "note1 copie.md"
# collision -> copie 2
r2 = client.post("/api/duplicate/TestVault", json={"source_path": "note1.md", "destination_dir": ""})
assert r2.json()["new_path"] == "note1 copie 2.md"
orig = client.get("/api/file/TestVault", params={"path": "note1.md"}).json()["html"]
copy = client.get("/api/file/TestVault", params={"path": "note1 copie.md"}).json()["html"]
assert copy == orig
def test_duplicate_directory_into_target_and_index(self, client):
assert client.post("/api/directory/TestVault", json={"path": "notes/sub"}).status_code == 200
assert client.post("/api/file/TestVault", json={"path": "notes/a.md", "content": "secret note A"}).status_code == 200
r = client.post("/api/duplicate/TestVault", json={"source_path": "notes", "destination_dir": "Projets"})
assert r.status_code == 200
new = r.json()["new_path"]
assert new == "Projets/notes copie"
paths = [p["path"] for p in client.get("/api/vault/TestVault/paths").json()["results"]]
assert any(p.startswith(new + "/") for p in paths), "copy not indexed"
def test_duplicate_directory_inside_itself_rejected(self, client):
assert client.post("/api/directory/TestVault", json={"path": "notes"}).status_code == 200
r = client.post("/api/duplicate/TestVault", json={"source_path": "notes", "destination_dir": "notes"})
assert r.status_code == 400
def test_duplicate_not_found(self, client):
r = client.post("/api/duplicate/TestVault", json={"source_path": "nonexistent.md", "destination_dir": ""})
assert r.status_code == 404
+7 -1
View File
@@ -251,11 +251,17 @@ class TestDependencySecurityFloors:
#: (paquet, plancher minimal, advisories corrigées au-dessus)
FLOORS = {
"pypdf": (6, 16, 1), # PYSEC-2026-3910, PYSEC-2026-3911 (fix 6.16.1)
# #224 : 6.19.0 (toolcache) frappée par 11 CVE, corrigées au plus
# tard en 6.21.2 ; avant : PYSEC-2026-3910/3911 (fix 6.16.1).
"pypdf": (6, 21, 2),
# BUG-095 : 2.13.0 a son propre advisory (CVE-2026-102274, fix 2.14.0).
"pyjwt": (2, 14, 0), # PYSEC-2026-178 (2.13.0) puis CVE-2026-102274 (2.14.0)
"urllib3": (2, 8, 0), # CVE-2026-97687, CVE-2026-97688, CVE-2026-97689 (fix 2.8.0)
"multidict": (6, 9, 1), # CVE-2026-104874 (fix 6.9.1 ; 6.7.x toolcache runner)
# #224 : transitives / préinstallées toolcache, auditées par pip-audit.
"brotli": (1, 2, 1), # PYSEC-2026-4322, GHSA-9v8h-qqgm-4m35 (fix 1.2.1)
"filelock": (4, 2, 1), # CVE-2026-112940, CVE-2026-112913, CVE-2026-112907 (fix 4.2.1)
"opentelemetry-api": (1, 45, 1), # CVE-2026-114603 (fix 1.45.1)
}
def test_security_floors_are_declared(self):
+680
View File
@@ -0,0 +1,680 @@
"""Tests du coffre Secrets ObsiGate (Phase 1a / 1b, issues #17 et #18).
Couverture exigée par les issues :
- chiffrement/déchiffrement, tag invalide refusé, rotations (secret, DEK,
KEK), mode dégradé sans KEK ;
- matrice d'autorisation, y compris admin contre coffre personnel d'autrui
et ACL « * » qui ne donne aucun droit sur un coffre ;
- contexte manquant refusé, octroi révoqué refusé au prochain appel,
expiration respectée, audit complet sans valeur ;
- lectures = métadonnées seules, jamais de valeur ni de texte chiffré.
Toutes les valeurs de secret utilisées ici sont fictives et ne servent
qu'aux tests. Le store est isolé par test via OBSIGATE_DATA_DIR et le
journal d'audit via backend.audit.AUDIT_LOG_FILE.
"""
from __future__ import annotations
import base64
import json
import os
import shutil
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from backend import audit as audit_module
from backend.auth.middleware import require_admin, require_auth
from backend.coffre_crypto import (
KEKAbsenteError,
KEKInvalideError,
TagInvalideError,
decrypt_secret_value,
encrypt_secret_value,
generate_dek,
generate_kek_b64,
load_kek,
parse_kek,
unwrap_dek,
wrap_dek,
)
from backend.coffre_store import (
CoffreExisteError,
SecretExpireError,
ValidationError,
create_coffre,
create_secret,
distribuer_valeur,
get_coffre_or_404,
rotate_dek,
rotate_kek,
rotate_secret,
)
from backend.routers.coffres import router as coffres_router
VALEUR_TEST = "VALEUR-TEST-FICTIVE-7f3a9c-ne-pas-utiliser"
VALEUR_TEST_2 = "VALEUR-TEST-FICTIVE-2-b4d8e1-ne-pas-utiliser"
# Utilisateur courant injecté dans les routes via dependency_overrides.
_COURANT = {"user": None}
def _fake_auth():
if _COURANT["user"] is None:
raise HTTPException(status_code=401, detail="Authentification requise")
return _COURANT["user"]
def _fake_admin():
user = _fake_auth()
if user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Accès admin requis")
return user
def _user(username: str, role: str = "user", vaults: list | None = None) -> dict:
return {
"username": username,
"role": role,
"vaults": vaults if vaults is not None else [f"home-{username}"],
"active": True,
}
@pytest.fixture
def env_coffre(tmp_path, monkeypatch):
"""Environnement isolé : store, audit, KEK et feature flag."""
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "1")
monkeypatch.setenv("OBSIGATE_DATA_DIR", str(tmp_path))
monkeypatch.setenv("OBSIGATE_KEK", generate_kek_b64())
monkeypatch.delenv("OBSIGATE_KEK_FILE", raising=False)
monkeypatch.setattr(audit_module, "AUDIT_LOG_FILE", tmp_path / "audit.log")
_COURANT["user"] = _user("bruno", role="admin")
yield tmp_path
_COURANT["user"] = None
@pytest.fixture
def client(env_coffre):
app = FastAPI()
app.include_router(coffres_router)
app.dependency_overrides[require_auth] = _fake_auth
app.dependency_overrides[require_admin] = _fake_admin
with TestClient(app, raise_server_exceptions=False) as test_client:
yield test_client
def _connecter(client, username: str, role: str = "user", vaults=None):
_COURANT["user"] = _user(username, role=role, vaults=vaults)
def _creer_coffre_secret(client, slug="personnel", nom="gitea-token", valeur=VALEUR_TEST):
resp = client.post(
"/api/coffres",
json={"slug": slug, "nom": "Personnel", "perimetre": "personnel"},
)
assert resp.status_code == 200, resp.text
resp = client.post(
f"/api/coffres/{slug}/secrets",
json={
"nom": nom,
"type": "token",
"service": "Gitea",
"valeur": valeur,
},
)
assert resp.status_code == 200, resp.text
return resp.json()
def _creer_consommateur_octroi(client, slug="personnel", nom="gitea-token"):
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
assert resp.status_code == 200, resp.text
consommateur = resp.json()
jeton = consommateur["jeton"]
resp = client.post(
f"/api/coffres/{slug}/octrois",
json={
"cible_type": "consommateur",
"cible_id": consommateur["id"],
"secret_nom": nom,
},
)
assert resp.status_code == 200, resp.text
return consommateur, jeton, resp.json()
def _distribuer(client, jeton, reference="coffre:personnel/gitea-token",
contexte_type="mission", contexte_id="mission-test-1"):
return client.post(
"/api/coffres/distribuer",
json={
"reference": reference,
"contexte_type": contexte_type,
"contexte_id": contexte_id,
},
headers={"Authorization": f"Bearer {jeton}"},
)
def _fichiers_sans_valeur(tmp_path, valeur=VALEUR_TEST):
for path in tmp_path.iterdir():
if path.is_file() and path.suffix in (".json", ".log"):
assert valeur not in path.read_text(encoding="utf-8"), path.name
# ---------------------------------------------------------------------------
# Crypto (Phase 1a)
# ---------------------------------------------------------------------------
class TestCrypto:
def test_aller_retour_aes_gcm(self):
kek = bytes(range(32))
dek = generate_dek()
wrapped = wrap_dek(dek, kek, "coffre-test")
assert unwrap_dek(wrapped, kek, "coffre-test") == dek
chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST)
assert decrypt_secret_value(dek, "secret-1", 1, chiffre["nonce"], chiffre["ciphertext"]) == VALEUR_TEST
def test_tag_invalide_refuse(self):
dek = generate_dek()
chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST)
alt = bytearray(base64.b64decode(chiffre["ciphertext"]))
alt[-1] ^= 1
chiffre_alt = {
"nonce": chiffre["nonce"],
"ciphertext": base64.b64encode(bytes(alt)).decode("ascii"),
}
with pytest.raises(TagInvalideError):
decrypt_secret_value(dek, "secret-1", 1, chiffre_alt["nonce"], chiffre_alt["ciphertext"])
def test_aad_incorrect_refuse(self):
dek = generate_dek()
chiffre = encrypt_secret_value(dek, "secret-1", 1, VALEUR_TEST)
with pytest.raises(TagInvalideError):
decrypt_secret_value(dek, "secret-2", 1, chiffre["nonce"], chiffre["ciphertext"])
def test_parse_kek_invalide(self, monkeypatch):
with pytest.raises(KEKInvalideError):
parse_kek("trop-court")
assert len(parse_kek(generate_kek_b64())) == 32
def test_kek_absente(self, tmp_path, monkeypatch):
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
monkeypatch.delenv("OBSIGATE_KEK_FILE", raising=False)
with pytest.raises(KEKAbsenteError):
load_kek()
def test_kek_fichier_0600_ok_et_trop_permissif_refuse(self, tmp_path, monkeypatch):
kek_file = tmp_path / "kek.txt"
kek_file.write_text(generate_kek_b64(), encoding="utf-8")
os.chmod(kek_file, 0o600)
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
monkeypatch.setenv("OBSIGATE_KEK_FILE", str(kek_file))
assert len(load_kek()) == 32
os.chmod(kek_file, 0o644)
with pytest.raises(KEKInvalideError):
load_kek()
# ---------------------------------------------------------------------------
# Store (Phase 1a)
# ---------------------------------------------------------------------------
class TestStore:
def test_roundtrip_et_aucun_clair_au_repos(self, env_coffre):
coffre = create_coffre("personnel", "Personnel", "bruno")
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST, service="Gitea")
assert distribuer_valeur(coffre, secret) == VALEUR_TEST
_fichiers_sans_valeur(env_coffre)
def test_rotation_secret(self, env_coffre):
coffre = create_coffre("personnel", "Personnel", "bruno")
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
secret = rotate_secret(coffre, "gitea-token", VALEUR_TEST_2)
assert secret["version_courante"] == 2
assert distribuer_valeur(coffre, secret) == VALEUR_TEST_2
def test_rotation_dek_puis_kek(self, env_coffre, monkeypatch):
coffre = create_coffre("personnel", "Personnel", "bruno")
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
rotate_dek("personnel")
assert distribuer_valeur(get_coffre_or_404("personnel"), secret) == VALEUR_TEST
nouvelle_kek = generate_kek_b64()
assert rotate_kek(nouvelle_kek) == 1
monkeypatch.setenv("OBSIGATE_KEK", nouvelle_kek)
assert distribuer_valeur(get_coffre_or_404("personnel"), secret) == VALEUR_TEST
def test_mode_degrade_sans_kek(self, env_coffre, monkeypatch):
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
coffre = create_coffre("personnel", "Personnel", "bruno")
assert coffre["dek_chiffree"] is None
with pytest.raises(KEKAbsenteError):
create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
def test_validation_et_doublons(self, env_coffre):
coffre = create_coffre("personnel", "Personnel", "bruno")
with pytest.raises(ValidationError):
create_coffre("MAUVAIS SLUG !!", "X", "bruno")
with pytest.raises(CoffreExisteError):
create_coffre("personnel", "Autre", "bruno")
create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
with pytest.raises(ValidationError):
create_secret(
coffre, "autre-secret", "token", VALEUR_TEST,
note="api-key: ceci-ressemble-a-un-secret",
)
def test_secret_expire_refuse_a_la_distribution(self, env_coffre):
coffre = create_coffre("personnel", "Personnel", "bruno")
hier = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
secret = create_secret(
coffre, "vieux-jeton", "token", VALEUR_TEST, expiration=hier
)
with pytest.raises(SecretExpireError):
distribuer_valeur(coffre, secret)
# ---------------------------------------------------------------------------
# API et autorisations (Phase 1b)
# ---------------------------------------------------------------------------
class TestApiAutorisations:
def test_flag_desactive_repond_503(self, client, monkeypatch):
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
assert client.get("/api/coffres").status_code == 503
def test_lectures_metadonnees_seules(self, client, env_coffre):
_creer_coffre_secret(client)
resp = client.get("/api/coffres/personnel/secrets")
assert resp.status_code == 200
corps = resp.text
assert VALEUR_TEST not in corps
assert "ciphertext" not in corps
assert "nonce" not in corps
item = resp.json()[0]
assert item["has_value"] is True
assert item["reference"] == "coffre:personnel/gitea-token"
assert "valeur" not in item
def test_admin_ne_voit_pas_le_coffre_personnel_d_autrui(self, client):
_creer_coffre_secret(client) # propriétaire : bruno (admin de test)
_connecter(client, "alice", role="user")
assert client.get("/api/coffres/personnel").status_code == 403
assert client.get("/api/coffres/personnel/secrets").status_code == 403
# Même un admin avec vaults=["*"] n'a aucun droit sur un coffre
# personnel dont il n'est pas propriétaire.
_connecter(client, "admin2", role="admin", vaults=["*"])
assert client.get("/api/coffres/personnel").status_code == 403
def test_acl_etoile_ne_donne_aucun_droit_coffre(self, client):
_creer_coffre_secret(client)
_connecter(client, "mallory", role="user", vaults=["*"])
assert client.get("/api/coffres/personnel/secrets").status_code == 403
def test_coffre_instance_reserve_admin(self, client):
_connecter(client, "bruno", role="user")
resp = client.post(
"/api/coffres",
json={"slug": "instance", "nom": "Instance", "perimetre": "instance"},
)
assert resp.status_code == 403
_connecter(client, "bruno", role="admin")
resp = client.post(
"/api/coffres",
json={"slug": "instance", "nom": "Instance", "perimetre": "instance"},
)
assert resp.status_code == 200
_connecter(client, "alice", role="user")
assert client.get("/api/coffres/instance").status_code == 403
def test_creation_secret_sans_kek_repond_503(self, client, monkeypatch):
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
resp = client.post("/api/coffres", json={"slug": "personnel", "nom": "Personnel"})
assert resp.status_code == 200 # métadonnées créées en mode dégradé
resp = client.post(
"/api/coffres/personnel/secrets",
json={"nom": "gitea-token", "type": "token", "valeur": VALEUR_TEST},
)
assert resp.status_code == 503
assert resp.json()["detail"] == "kek_absente"
class TestDistribution:
def test_distribution_ok_puis_revoquee_puis_consommateur_revoque(self, client, env_coffre):
_creer_coffre_secret(client)
consommateur, jeton, octroi = _creer_consommateur_octroi(client)
resp = _distribuer(client, jeton)
assert resp.status_code == 200, resp.text
assert resp.json()["valeur"] == VALEUR_TEST
assert resp.json()["version"] == 1
# Le jeton complet n'est jamais relisté, ni son hash (seul le
# préfixe d'affichage, non exploitable, apparaît).
liste = client.get("/api/coffres/consommateurs").json()
assert jeton not in json.dumps(liste)
assert "jeton_hash" not in json.dumps(liste)
assert all(set(item.keys()) == {"id", "nom", "jeton_prefixe", "statut", "cree_par", "cree_le"} for item in liste)
# Révocation de l'octroi : refusé dès le prochain appel.
resp = client.delete(f"/api/coffres/octrois/{octroi['id']}")
assert resp.status_code == 200
resp = _distribuer(client, jeton)
assert resp.status_code == 403
assert resp.json()["detail"] == "octroi_revoque"
# Révocation du consommateur : tous ses octrois tombent.
resp = client.post(
"/api/coffres/personnel/octrois",
json={"cible_type": "consommateur", "cible_id": consommateur["id"], "secret_nom": "gitea-token"},
)
assert resp.status_code == 200
resp = client.delete(f"/api/coffres/consommateurs/{consommateur['id']}")
assert resp.status_code == 200
resp = _distribuer(client, jeton)
assert resp.status_code == 403
assert resp.json()["detail"] == "consommateur_revoque"
def test_contexte_manquant_refuse(self, client):
_creer_coffre_secret(client)
_, jeton, _ = _creer_consommateur_octroi(client)
resp = client.post(
"/api/coffres/distribuer",
json={"reference": "coffre:personnel/gitea-token"},
headers={"Authorization": f"Bearer {jeton}"},
)
assert resp.status_code == 400
assert resp.json()["detail"] == "contexte_manquant"
def test_session_utilisateur_ne_distribue_pas(self, client):
_creer_coffre_secret(client)
_creer_consommateur_octroi(client)
resp = _distribuer(client, "jeton-session-utilisateur-fictif")
assert resp.status_code == 401
assert resp.json()["detail"] == "consommateur_invalide"
def test_octroi_absent_et_secret_expire(self, client):
_creer_coffre_secret(client)
resp = client.post("/api/coffres/consommateurs", json={"nom": "sans-octroi"})
jeton = resp.json()["jeton"]
resp = _distribuer(client, jeton)
assert resp.status_code == 403
assert resp.json()["detail"] == "octroi_absent"
# Secret expiré : 410, même avec un octroi actif.
_connecter(client, "bruno", role="admin")
hier = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
resp = client.post(
"/api/coffres/personnel/secrets",
json={"nom": "vieux-jeton", "type": "token", "valeur": VALEUR_TEST, "expiration": hier},
)
assert resp.status_code == 200
_, jeton2, _ = _creer_consommateur_octroi(client, nom="vieux-jeton")
resp = _distribuer(client, jeton2, reference="coffre:personnel/vieux-jeton")
assert resp.status_code == 410
assert resp.json()["detail"] == "secret_expire"
def test_rotation_change_la_valeur_distribuee(self, client):
_creer_coffre_secret(client)
_, jeton, _ = _creer_consommateur_octroi(client)
resp = client.post(
"/api/coffres/personnel/secrets/gitea-token/rotation",
json={"valeur": VALEUR_TEST_2},
)
assert resp.status_code == 200
assert resp.json()["version"] == 2
resp = _distribuer(client, jeton)
assert resp.json()["valeur"] == VALEUR_TEST_2
def test_suppression_casse_les_octrois(self, client):
_creer_coffre_secret(client)
_, jeton, _ = _creer_consommateur_octroi(client)
resp = client.delete("/api/coffres/personnel/secrets/gitea-token")
assert resp.status_code == 200
resp = _distribuer(client, jeton)
assert resp.status_code == 403
assert resp.json()["detail"] == "octroi_absent"
def test_octroi_par_defaut_90_jours(self, client):
_creer_coffre_secret(client)
_, _, octroi = _creer_consommateur_octroi(client)
expiration = datetime.fromisoformat(octroi["expiration"])
delta = expiration - datetime.now(timezone.utc)
assert 89 <= delta.days <= 91
def test_audit_sans_valeur_et_journal(self, client, env_coffre):
_creer_coffre_secret(client)
_, jeton, _ = _creer_consommateur_octroi(client)
assert _distribuer(client, jeton).status_code == 200
_fichiers_sans_valeur(env_coffre)
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
assert "distribution_ok" in audit
assert VALEUR_TEST not in audit
resp = client.get("/api/coffres/personnel/journal")
assert resp.status_code == 200
actions = [e["action"] for e in resp.json()]
assert "secret_cree" in actions
assert "distribution_ok" in actions
assert "valeur" not in json.dumps(resp.json())
# ---------------------------------------------------------------------------
# Phase 2 — Migration api_keys.json (issue #20)
# ---------------------------------------------------------------------------
class TestMigrationApiKeys:
def _ecrire_api_keys(self, tmp_path):
(tmp_path / "api_keys.json").write_text(
json.dumps(
{
"DEEPSEEK_API_KEY": VALEUR_TEST,
"OBSIGATE_GITEA_TOKEN": VALEUR_TEST_2,
"OBSIGATE_GITEA_URL": "https://example.invalid",
"MISTRAL_API_KEY": "",
}
),
encoding="utf-8",
)
def test_apercu_import_purge(self, client, env_coffre):
self._ecrire_api_keys(env_coffre)
resp = client.get("/api/coffres/migration/api-keys")
assert resp.status_code == 200, resp.text
# Aperçu = métadonnées seules, jamais une valeur.
assert VALEUR_TEST not in resp.text
assert VALEUR_TEST_2 not in resp.text
candidats = {c["nom"]: c for c in resp.json()["candidats"]}
assert candidats["deepseek_api_key"]["has_value"] is True
assert candidats["mistral_api_key"]["has_value"] is False
assert "obsigate_gitea_url" not in candidats # URL, pas un secret
# Import : ne supprime rien dans api_keys.json.
resp = client.post("/api/coffres/migration/api-keys/import", json={})
assert resp.status_code == 200, resp.text
resultat = resp.json()
assert resultat["coffre"] == "obsigate-interne"
assert "deepseek_api_key" in resultat["migres"]
assert "obsigate_gitea_token" in resultat["migres"]
assert "mistral_api_key" in resultat["sans_valeur"]
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
assert cles["DEEPSEEK_API_KEY"] == VALEUR_TEST
# Les secrets migrés sont visibles en métadonnées et distribuables.
resp = client.get("/api/coffres/obsigate-interne/secrets")
assert resp.status_code == 200
assert VALEUR_TEST not in resp.text
assert any(s["nom"] == "deepseek_api_key" and s["has_value"] for s in resp.json())
_, jeton, _ = _creer_consommateur_octroi(
client, slug="obsigate-interne", nom="deepseek_api_key"
)
resp = _distribuer(
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
)
assert resp.status_code == 200, resp.text
assert resp.json()["valeur"] == VALEUR_TEST
# Purge sans confirmation : refusée, fichier intact.
resp = client.post(
"/api/coffres/migration/api-keys/purger",
json={"confirmer": False, "noms": ["deepseek_api_key"]},
)
assert resp.status_code == 400
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
assert "DEEPSEEK_API_KEY" in cles
# Purge confirmée : seules les clés listées disparaissent.
resp = client.post(
"/api/coffres/migration/api-keys/purger",
json={"confirmer": True, "noms": ["deepseek_api_key", "obsigate_gitea_token"]},
)
assert resp.status_code == 200, resp.text
assert set(resp.json()["supprimes"]) == {
"deepseek_api_key",
"obsigate_gitea_token",
}
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
assert "DEEPSEEK_API_KEY" not in cles
assert "OBSIGATE_GITEA_TOKEN" not in cles
assert cles["OBSIGATE_GITEA_URL"] == "https://example.invalid"
# Audit sans valeur.
_fichiers_sans_valeur(env_coffre)
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
assert "migration_api_keys" in audit
assert "migration_api_keys_purge" in audit
def test_import_ne_ecrase_pas_existant(self, client, env_coffre):
self._ecrire_api_keys(env_coffre)
assert client.post("/api/coffres/migration/api-keys/import", json={}).status_code == 200
(env_coffre / "api_keys.json").write_text(
json.dumps({"DEEPSEEK_API_KEY": VALEUR_TEST_2}), encoding="utf-8"
)
resp = client.post("/api/coffres/migration/api-keys/import", json={})
assert resp.status_code == 200
assert "deepseek_api_key" in resp.json()["deja_existants"]
_, jeton, _ = _creer_consommateur_octroi(
client, slug="obsigate-interne", nom="deepseek_api_key"
)
resp = _distribuer(
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
)
assert resp.json()["valeur"] == VALEUR_TEST # valeur d'origine conservée
def test_import_sans_kek_repond_503(self, client, env_coffre, monkeypatch):
# Une clé à migrer est nécessaire : sans valeur à chiffrer,
# l'import réussit trivialement même sans KEK (mode dégradé).
self._ecrire_api_keys(env_coffre)
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
resp = client.post("/api/coffres/migration/api-keys/import", json={})
assert resp.status_code == 503
assert resp.json()["detail"] == "kek_absente"
# ---------------------------------------------------------------------------
# Phase 2 — Restauration sur instance vierge (issue #20)
# ---------------------------------------------------------------------------
class TestRestaurationVierge:
def test_restauration_meme_kek_distribue(self, env_coffre, tmp_path):
coffre = create_coffre("personnel", "Personnel", "bruno")
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
assert distribuer_valeur(coffre, secret) == VALEUR_TEST
# Simulation d'une instance vierge : seuls les deux fichiers sont
# copiés, la KEK (variable d'environnement) est inchangée.
restaure = tmp_path / "restaure"
restaure.mkdir()
for nom in ("coffres.json", "coffres_valeurs.json"):
shutil.copy(env_coffre / nom, restaure / nom)
coffre_r = get_coffre_or_404("personnel", restaure)
assert distribuer_valeur(coffre_r, secret, restaure) == VALEUR_TEST
for nom in ("coffres.json", "coffres_valeurs.json"):
assert VALEUR_TEST not in (restaure / nom).read_text(encoding="utf-8")
def test_restauration_mauvaise_kek_refuse(self, env_coffre, tmp_path, monkeypatch):
coffre = create_coffre("personnel", "Personnel", "bruno")
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
restaure = tmp_path / "restaure"
restaure.mkdir()
for nom in ("coffres.json", "coffres_valeurs.json"):
shutil.copy(env_coffre / nom, restaure / nom)
# Une autre KEK ne déchiffre rien : tag GCM invalide, pas de valeur.
monkeypatch.setenv("OBSIGATE_KEK", generate_kek_b64())
coffre_r = get_coffre_or_404("personnel", restaure)
with pytest.raises(TagInvalideError):
distribuer_valeur(coffre_r, secret, restaure)
# ---------------------------------------------------------------------------
# Phase 3 — Contexte Spark imbriqué (issue #21)
# ---------------------------------------------------------------------------
class TestDistributionSpark:
def test_contexte_nested_mission(self, client):
_creer_coffre_secret(client)
_, jeton, _ = _creer_consommateur_octroi(client)
resp = client.post(
"/api/coffres/distribuer",
json={
"reference": "coffre:personnel/gitea-token",
"contexte": {"type": "mission", "id": "mission-test-2"},
},
headers={"Authorization": f"Bearer {jeton}"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["valeur"] == VALEUR_TEST
def test_contexte_nested_sans_octroi_acces_requis(self, client):
_creer_coffre_secret(client)
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
jeton = resp.json()["jeton"]
resp = client.post(
"/api/coffres/distribuer",
json={
"reference": "coffre:personnel/gitea-token",
"contexte": {"type": "mission", "id": "mission-test-3"},
},
headers={"Authorization": f"Bearer {jeton}"},
)
# Côté Spark, ce code se traduit par l'état acces_requis.
assert resp.status_code == 403
assert resp.json()["detail"] == "octroi_absent"
# ---------------------------------------------------------------------------
# Phase 4 — Consommateur + octrois en un appel (issue #22)
# ---------------------------------------------------------------------------
class TestConsommateurAvecOctrois:
def test_avec_octrois_distribue(self, client, env_coffre):
_creer_coffre_secret(client)
resp = client.post(
"/api/coffres/consommateurs/avec-octrois",
json={
"nom": "tiktak",
"octrois": [{"coffre_slug": "personnel", "secret_nom": "gitea-token"}],
},
)
assert resp.status_code == 200, resp.text
corps = resp.json()
assert corps["nom"] == "tiktak"
assert corps["jeton"].startswith("csk_")
assert len(corps["octrois"]) == 1
assert corps["erreurs"] == []
resp = _distribuer(client, corps["jeton"])
assert resp.status_code == 200
assert resp.json()["valeur"] == VALEUR_TEST
liste = client.get("/api/coffres/consommateurs").json()
assert corps["jeton"] not in json.dumps(liste)
assert "jeton_hash" not in json.dumps(liste)
def test_avec_octrois_erreur_signalee_sans_annuler(self, client):
_creer_coffre_secret(client)
resp = client.post(
"/api/coffres/consommateurs/avec-octrois",
json={
"nom": "flowdeck",
"octrois": [
{"coffre_slug": "personnel", "secret_nom": "gitea-token"},
{"coffre_slug": "inexistant", "secret_nom": "gitea-token"},
],
},
)
assert resp.status_code == 200, resp.text
assert len(resp.json()["octrois"]) == 1
assert len(resp.json()["erreurs"]) == 1
assert resp.json()["erreurs"][0]["coffre_slug"] == "inexistant"
+148
View File
@@ -0,0 +1,148 @@
"""BUG-118 — barre d'apps commune en mobile (Fusion ACC-1, #215).
Retours de Bruno (2026-10-21) sur téléphone : la barre débordait
(pastilles domaines et onglets entiers affichés) et son bouton ☰
ne faisait rien — il cliquait le bouton bureau
``sidebar-toggle-btn`` au lieu du vrai hamburger mobile
``hamburger-btn``. Correctifs verrouillés ici : CSS commune v0.1.2
(palier 700 px resserré selon la spec) et rail contextuel dans
index.html (mobile ≤ 768 px → hamburger-btn).
"""
from pathlib import Path
FRONTEND = Path(__file__).resolve().parent.parent / "frontend"
def _css() -> str:
return (FRONTEND / "draco-barre.css").read_text(encoding="utf-8")
def _index() -> str:
return (FRONTEND / "index.html").read_text(encoding="utf-8")
def test_palier_mobile_resserre_v012():
css = _css()
assert "@media (max-width: 900px)" in css
# Seul l'onglet de l'app courante reste visible en mobile.
assert '.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"]' in css
# Domaines et boutons icônes (rail, menu) masqués en mobile.
bloc = css.split("@media (max-width: 900px)", 1)[1]
assert ".draco-domaines" in bloc
assert "display: none" in bloc
def test_menu_des_applications_par_la_marque():
# v0.1.3 : la marque ✦ ouvre le menu des 4 applications
# (changement d'app en mobile), logo à 24 px.
html = _index()
assert 'data-act="apps"' in html
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
assert "draco-apps-menu" in js
assert "font-size: 24px" in _css()
def test_panneau_spark_integre_218():
# #218 : le bouton Spark de la barre ouvre le panneau de l'agent
# commun (mêmes assets partagés que TikTak / Flowdeck / Kanbante).
html = _index()
assert (FRONTEND / "draco-panneau.js").is_file()
assert (FRONTEND / "draco-panneau.css").is_file()
assert '<link rel="stylesheet" href="/static/draco-panneau.css" />' in html
assert '<script defer src="/static/draco-panneau.js"></script>' in html
# L'action spark bascule sur le panneau — plus le message d'attente.
assert "window.DRACO_PANNEAU.toggle()" in html
assert "arrive en phase 4 du programme Fusion" not in html
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
assert "https://agent.dracodev.net" in panneau
def test_panneau_bouton_console_221():
# #221 : le panneau Spark (asset vendangé, octet-identique dans
# les 4 apps) porte un bouton « Console » qui mène à la console
# web du service, sur la discussion courante (?disc=) et avec
# l'app d'origine (?app= déduite du sous-domaine : og → obsigate).
# Révisé par #222 : l'ouverture en nouvel onglet a disparu (la
# console s'ouvre en surimpression, voir le test #222) ; le lien
# profond, lui, subsiste via urlConsole().
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
assert 'data-disc="console"' in panneau
assert "ouvrirConsole" in panneau
assert "urlConsole" in panneau
assert '"disc=" + encodeURIComponent(id)' in panneau
assert '"app=" + encodeURIComponent(app)' in panneau
assert 'og: "obsigate"' in panneau
assert 'window.open(' not in panneau
def test_panneau_console_surimpression_222():
# #222 : le bouton « Console ⤢ » ouvre la console dans la même
# page, en surimpression née à la largeur du panneau et étendue
# à toute la fenêtre (transition sur la largeur), avec deux
# sorties : « Mode sidebar » et « Fermer l'agent ». L'URL de
# l'iframe garde le lien profond de #221 (urlConsole()).
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
assert "Console ⤢" in panneau
assert "draco-console-sur" in panneau
assert "assurerSurimpression" in panneau
assert "draco-console-styles" in panneau
assert 'surCadre.src = urlConsole()' in panneau
assert 'surEl.style.width = "100%"' in panneau
assert 'data-dc="sidebar"' in panneau
assert "Mode sidebar" in panneau
assert "modeSidebarConsole" in panneau
assert 'data-dc="fermer"' in panneau
assert "Fermer l'agent" in panneau
assert "fermerAgentConsole" in panneau
def test_csp_ouvre_le_service_d_agents_218():
# Le panneau parle au service d'agents : la CSP doit nommer son
# hôte en connect-src, sans ouvrir de générique supplémentaire.
# #223 : l'origine n'est plus un littéral dans main.py — elle est
# dérivée de la configuration (backend.draco_config.agent_origine,
# testée via la config dans tests/test_security_headers.py).
main = (Path(__file__).resolve().parent.parent / "backend" / "main.py").read_text(encoding="utf-8")
ligne = next(l for l in main.splitlines() if "connect-src" in l)
assert "agent_origine" in main
assert "{agent}" in ligne
def test_urls_ecosysteme_via_injection_hote_223():
# #223 : panneau Spark, liens TikTak et onglets des apps lisent
# window.DRACO_URLS (injecté par l'hôte/backend depuis sa config) ;
# les littéraux restants ne sont que les défauts officiels en repli.
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
assert "window.DRACO_URLS" in panneau
assert "DRACO_URLS.agent" in panneau
liens = (FRONTEND / "draco-liens.js").read_text(encoding="utf-8")
assert "window.DRACO_URLS" in liens
assert "baseTikTak()" in liens
html = _index()
assert "window.DRACO_URLS" in html
assert "DRACO_URLS[a.dataset.app]" in html
def test_rail_mobile_vers_le_vrai_hamburger():
html = _index()
assert 'matchMedia("(max-width: 768px)")' in html
assert '"hamburger-btn"' in html
assert '"sidebar-toggle-btn"' in html
def test_panneau_version_service_affichee_225():
# #225 : le pied du panneau Spark (mode sidebar) affiche la
# version du service d'agents, comme la console pleine page.
# La version est lue en direct dans la santé du service
# (GET /healthz de l'origine injectée par l'hôte), à l'ouverture
# du panneau ; jamais un littéral de version dans l'asset, et
# source injoignable = ligne laissée masquée (rien d'affiché).
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
assert "draco-panneau-version" in panneau
assert "chargerVersion" in panneau
assert 'API + "/healthz"' in panneau
assert '"Service d\'agents v" + versionConnue' in panneau
# La ligne naît masquée et n'est démasquée qu'avec une version lue.
assert 'class="draco-panneau-version"' in panneau
assert "versionEl.hidden = false" in panneau
+184
View File
@@ -0,0 +1,184 @@
"""URLs publiques de l'écosystème par configuration — #223 (dev/prod).
Règle : aucun nom public de l'écosystème n'est figé dans le code
vivant. Défauts = noms **officiels** (production future) ; le dev
déclare ses noms en ``-dev`` dans son seul ``.env``
(``DRACO_*_URL`` / ``AGENT_EVENEMENTS_URL``). On vérifie ici la
config centralisée, la dérivation de l'URL d'événements du journal,
la reconnaissance des liens profonds des deux environnements et la
reconstruction des URL d'affichage des références croisées.
"""
import pytest
from backend import draco_config
@pytest.fixture()
def env_propre(monkeypatch):
"""Aucune variable d'URL d'écosystème posée (défauts officiels)."""
for nom in (
"DRACO_AGENT_URL", "DRACO_OBSIGATE_URL", "DRACO_FLOWDECK_URL",
"DRACO_TIKTAK_URL", "DRACO_KANBANTE_URL", "DRACO_GITEA_URL",
"AGENT_EVENEMENTS_URL",
):
monkeypatch.delenv(nom, raising=False)
return monkeypatch
def test_defauts_officiels_sans_dev(env_propre):
assert draco_config.agent_url() == "https://agent.dracodev.net"
assert draco_config.app_url("tiktak") == "https://tiktak.dracodev.net"
assert draco_config.app_url("obsigate") == "https://og.dracodev.net"
assert draco_config.app_url("flowdeck") == "https://flowdeck.dracodev.net"
assert draco_config.app_url("kanbante") == "https://kanbante.dracodev.net"
assert draco_config.app_url("gitea") == "https://git.dracodev.net"
for url in draco_config.urls_publiques().values():
assert "-dev" not in url
def test_agent_derive_des_evenements(env_propre):
"""Sans DRACO_AGENT_URL, l'origine suit AGENT_EVENEMENTS_URL —
la configuration réelle du poste (dev inclus)."""
env_propre.setenv(
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
assert draco_config.agent_url() == "https://agent-dev.dracodev.net"
assert draco_config.agent_origine() == "https://agent-dev.dracodev.net"
def test_agent_explicite_prime(env_propre):
env_propre.setenv(
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
env_propre.setenv("DRACO_AGENT_URL", "https://agent.example.net/")
assert draco_config.agent_url() == "https://agent.example.net"
# ------------------------------------------------- BUG-120 : dérivation sœurs
def test_soeurs_derivees_depuis_agent_dev(env_propre):
"""BUG-120 : avec **seul** ``AGENT_EVENEMENTS_URL`` en ``-dev``
posé (aucune ``DRACO_<APP>_URL``), les URL sœurs servies suivent
l'environnement de l'agent ; Gitea ne bouge jamais."""
env_propre.setenv(
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
urls = draco_config.urls_publiques()
assert urls["agent"] == "https://agent-dev.dracodev.net"
assert urls["obsigate"] == "https://og-dev.dracodev.net"
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
assert urls["tiktak"] == "https://tiktak-dev.dracodev.net"
assert urls["kanbante"] == "https://kanbante-dev.dracodev.net"
assert urls["gitea"] == "https://git.dracodev.net"
def test_soeurs_derivees_depuis_draco_agent_url(env_propre):
"""BUG-120 : la dérivation lit l'agent via ``agent_url()`` — un
``DRACO_AGENT_URL`` en ``-dev`` suffit aussi."""
env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
assert draco_config.app_url("tiktak") == "https://tiktak-dev.dracodev.net"
assert draco_config.app_url("obsigate") == "https://og-dev.dracodev.net"
def test_soeur_explicite_prime_sur_derivation(env_propre):
"""BUG-120 : une ``DRACO_<APP>_URL`` explicite gagne toujours sur
la dérivation d'environnement ; les autres sœurs dérivent."""
env_propre.setenv(
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
env_propre.setenv("DRACO_TIKTAK_URL", "https://taches.example.net/")
assert draco_config.app_url("tiktak") == "https://taches.example.net"
assert draco_config.app_url("flowdeck") == "https://flowdeck-dev.dracodev.net"
assert draco_config.app_url("kanbante") == "https://kanbante-dev.dracodev.net"
@pytest.mark.parametrize("evenements", [
"https://agent.example.net/api/v1/evenements", # autre domaine
"https://agent-dev.example.com/api/v1/evenements", # suffixe hors dracodev.net
"http://deepin.lab.home:8790/api/v1/evenements", # nom LAN + port
"http://192.168.30.101:8790/api/v1/evenements", # IP + port
"https://agent-dev.dracodev.net:8790/api/v1/evenements", # port exotique
])
def test_agent_hors_motif_aucune_derivation(env_propre, evenements):
"""BUG-120 : hôte d'agent hors motif ``agent<SUFFIXE>.dracodev.net``
→ **aucune dérivation** (jamais de lien deviné), défauts officiels."""
env_propre.setenv("AGENT_EVENEMENTS_URL", evenements)
urls = draco_config.urls_publiques()
assert urls["obsigate"] == "https://og.dracodev.net"
assert urls["flowdeck"] == "https://flowdeck.dracodev.net"
assert urls["tiktak"] == "https://tiktak.dracodev.net"
assert urls["kanbante"] == "https://kanbante.dracodev.net"
assert urls["gitea"] == "https://git.dracodev.net"
def test_agent_prod_suffixe_vide_defauts_officiels(env_propre):
"""BUG-120 : hôte d'agent prod (suffixe vide) → les sœurs dérivées
sont exactement les défauts officiels."""
env_propre.setenv(
"AGENT_EVENEMENTS_URL", "https://agent.dracodev.net/api/v1/evenements")
for app, defaut in draco_config.DEFAUTS.items():
assert draco_config.app_url(app) == defaut
def test_journal_url_suit_la_config(env_propre):
from backend import agent_journal
assert agent_journal._url() == "https://agent.dracodev.net/api/v1/evenements"
env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
assert agent_journal._url() == "https://agent-dev.dracodev.net/api/v1/evenements"
env_propre.setenv(
"AGENT_EVENEMENTS_URL", "https://autre.example.net/api/v1/evenements")
assert agent_journal._url() == "https://autre.example.net/api/v1/evenements"
# ------------------------------------------------------------ liens profonds
def test_parse_hotes_configures(env_propre):
"""Un lien collé depuis l'environnement dev (hôtes déclarés dans
son .env) est reconnu, comme un lien officiel."""
from backend.external_refs import parse_deep_link
env_propre.setenv("DRACO_TIKTAK_URL", "https://tiktak-dev.dracodev.net")
env_propre.setenv("DRACO_OBSIGATE_URL", "https://og-dev.dracodev.net")
assert parse_deep_link("https://tiktak-dev.dracodev.net/?task=5") == (
"tiktak", "task", "5", "TikTak · tâche 5")
assert parse_deep_link("https://og-dev.dracodev.net/#file=v:Dossier/Note.md") == (
"obsigate", "note", "v:Dossier/Note.md", "ObsiGate · Note.md")
# L'officiel reste reconnu en parallèle (lien venu de la prod).
assert parse_deep_link("https://tiktak.dracodev.net/?task=5") is not None
def test_url_affichage_reconstruite_depuis_config(env_propre, tmp_path):
"""#223 : la référence stockée garde son URL absolue d'origine
(aucune migration), mais l'URL **servie** est reconstruite depuis
la config courante."""
from backend.external_refs import add_ref, build_deep_link, list_refs
env_propre.setenv("OBSIGATE_EXTERNAL_REFS_DIR", str(tmp_path / "refs"))
assert build_deep_link("tiktak", "task", "42") == \
"https://tiktak.dracodev.net/?task=42"
assert build_deep_link("kanbante", "card", "Projets/obsigate#7") == \
"https://git.dracodev.net/Projets/obsigate/issues/7"
assert build_deep_link("obsigate", "note", "v:Dossier/Note.md") == \
"https://og.dracodev.net/#file=v:Dossier%2FNote.md"
env_propre.setenv("DRACO_TIKTAK_URL", "https://tiktak-dev.dracodev.net")
ref = add_ref("alice", "Vault", "N.md", "tiktak", "task", "42",
"https://tiktak.dracodev.net/?task=42", "TikTak · tâche 42")
assert ref["target_url"] == "https://tiktak.dracodev.net/?task=42" # stocké tel quel
servies = list_refs("alice", "Vault", "N.md")
assert servies[0]["target_url"] == "https://tiktak-dev.dracodev.net/?task=42"
# ------------------------------------------------------------ injection hôte
def test_index_injecte_draco_urls(client, env_propre):
"""L'hôte sert index.html avec window.DRACO_URLS issu de sa config.
BUG-120 : l'agent en ``-dev`` entraîne les sœurs en ``-dev``
(dérivation d'environnement) — avant, ``tiktak`` restait en prod.
"""
env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
resp = client.get("/")
assert resp.status_code == 200
assert "window.DRACO_URLS" in resp.text
assert '"agent": "https://agent-dev.dracodev.net"' in resp.text
assert '"tiktak": "https://tiktak-dev.dracodev.net"' in resp.text
assert '"gitea": "https://git.dracodev.net"' in resp.text
+234
View File
@@ -0,0 +1,234 @@
# tests/test_draco_session.py
# Fusion ACC-3.1 (#216) — émettrice SSO draco_session : émission à la
# connexion, vérification (signature, expiration, révocation, rotation),
# renouvellement glissant, return_to en liste blanche, révocations.
import time
import pytest
from jose import jwt as jose_jwt
from backend import draco_session
USER = {"id": "admin-1", "username": "admin", "display_name": "Admin"}
@pytest.fixture(autouse=True)
def _draco_env(monkeypatch, tmp_path):
monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test")
monkeypatch.setattr(draco_session, "REVOKED_FILE",
tmp_path / "draco_revoked.json")
draco_session._revoked = None
yield
draco_session._revoked = None
# ── Jeton : émission / vérification ─────────────────────────────────────
def test_roundtrip_claims_minimaux():
token, jti, exp = draco_session.issue_token(USER)
claims = draco_session.verify_token(token)
assert claims is not None
assert claims["sub"] == "admin-1"
assert claims["name"] == "Admin"
assert claims["jti"] == jti
assert exp - claims["iat"] == 12 * 3600 # durée de vie du design §5.2
assert "role" not in claims # aucun rôle dans le jeton
def test_jeton_falsifie_rejete():
token, _j, _e = draco_session.issue_token(USER)
assert draco_session.verify_token(token + "x") is None
autre = jose_jwt.encode({"sub": "admin-1"}, "mauvais-secret",
algorithm="HS256")
assert draco_session.verify_token(autre) is None
def test_jeton_expire_rejete():
now = int(time.time())
token = jose_jwt.encode(
{"sub": "admin-1", "name": "Admin", "iat": now - 7200,
"exp": now - 3600, "jti": "ancien"},
"secret-de-test", algorithm="HS256")
assert draco_session.verify_token(token) is None
def test_rotation_secret_precedent_accepte(monkeypatch):
token = jose_jwt.encode(
{"sub": "admin-1", "name": "Admin", "iat": int(time.time()),
"exp": int(time.time()) + 3600, "jti": "rotation"},
"ancien-secret", algorithm="HS256")
assert draco_session.verify_token(token) is None # pas encore configuré
monkeypatch.setenv("DRACO_SESSION_SECRET_PREV", "ancien-secret")
assert draco_session.verify_token(token)["jti"] == "rotation"
def test_revocation():
token, jti, exp = draco_session.issue_token(USER)
assert draco_session.verify_token(token) is not None
draco_session.revoke(jti, exp)
assert draco_session.verify_token(token) is None
assert {"jti": jti, "exp": exp} in draco_session.revoked_list()
def test_inerte_sans_secret(monkeypatch):
monkeypatch.delenv("DRACO_SESSION_SECRET")
assert draco_session.enabled() is False
assert draco_session.verify_token("peu-importe") is None
def test_fenetre_de_renouvellement():
assert draco_session.needs_renewal({"exp": int(time.time()) + 1800})
assert not draco_session.needs_renewal({"exp": int(time.time()) + 12 * 3600})
# ── return_to : liste blanche ────────────────────────────────────────────
@pytest.mark.parametrize("url,ok", [
("https://tiktak.dracodev.net/?task=3", True),
("https://og.dracodev.net/", True),
("https://dracodev.net/x", True),
("http://tiktak.dracodev.net/", False), # https seulement
("https://evil.com/", False),
("https://tiktak.dracodev.net.evil.com/", False),
("https://notdracodev.net/", False),
("", False),
])
def test_validate_return_to(url, ok):
assert (draco_session.validate_return_to(url) is not None) == ok
# ── Intégration : connexion / déconnexion / renouvellement ──────────────
def _login(client):
r = client.post("/api/auth/login",
json={"username": "admin", "password": "chab30"})
assert r.status_code == 200, r.text
return r
def _draco_cookie(response):
for raw in response.headers.get_list("set-cookie"):
if raw.startswith("draco_session="):
return raw
return None
def test_login_pose_le_cookie(admin_client):
r = _login(admin_client)
cookie = _draco_cookie(r)
assert cookie is not None
assert "Domain=.dracodev.net" in cookie
assert "HttpOnly" in cookie and "SameSite=lax" in cookie
token = cookie.split(";")[0].split("=", 1)[1]
assert draco_session.verify_token(token)["sub"] == "admin-1"
def test_login_sans_secret_aucun_cookie(admin_client, monkeypatch):
monkeypatch.delenv("DRACO_SESSION_SECRET")
r = _login(admin_client)
assert _draco_cookie(r) is None
def _token_depuis_login(client):
# Le jar du TestClient refuse le cookie Domain=.dracodev.net (hôte
# de test différent) : on lit le jeton dans l'en-tête set-cookie.
cookie = _draco_cookie(_login(client))
assert cookie is not None
return cookie.split(";")[0].split("=", 1)[1]
def test_logout_revoque_le_jti(admin_client):
token = _token_depuis_login(admin_client)
assert draco_session.verify_token(token)
r = admin_client.post("/api/auth/logout",
headers={"Cookie": f"draco_session={token}"})
assert r.status_code == 200, r.text
assert draco_session.verify_token(token) is None # révoqué
def test_renouvellement_glissant(admin_client):
_login(admin_client)
# Cookie draco presque expiré (dans la fenêtre des 2 h) :
now = int(time.time())
vieux = jose_jwt.encode(
{"sub": "admin-1", "name": "Admin", "iat": now - 11 * 3600,
"exp": now + 600, "jti": "vieux-jti"},
"secret-de-test", algorithm="HS256")
admin_client.cookies.set("draco_session", vieux)
r = admin_client.get("/api/auth/me")
assert r.status_code == 200, r.text
cookie = _draco_cookie(r)
assert cookie is not None # réémis
nouveau = cookie.split(";")[0].split("=", 1)[1]
assert draco_session.verify_token(nouveau)["jti"] != "vieux-jti"
def test_pas_de_renouvellement_si_frais(admin_client):
token = _token_depuis_login(admin_client)
admin_client.cookies.set("draco_session", token) # cookie frais porté
r = admin_client.get("/api/auth/me")
assert r.status_code == 200
assert _draco_cookie(r) is None # cookie frais : rien
def test_sso_authorize_liste_blanche(client):
r = client.get("/sso/authorize",
params={"return_to": "https://tiktak.dracodev.net/?task=3"},
follow_redirects=False)
assert r.status_code == 302
assert "return_to=" in r.headers["location"]
r = client.get("/sso/authorize",
params={"return_to": "https://evil.com/phish"},
follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/" # refusé, pas de relais
# ── Validation locale (ACC-3.2, #9) ──────────────────────────────────────
def test_me_avec_cookie_draco_seul(admin_client):
# Aucune session native : le cookie partagé suffit (sub = id).
token, _jti, _exp = draco_session.issue_token(USER)
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 200, r.text
assert r.json()["username"] == "admin"
def test_me_draco_revoque_refuse(admin_client):
token, jti, exp = draco_session.issue_token(USER)
draco_session.revoke(jti, exp)
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 401
def test_me_draco_sub_inconnu_refuse(admin_client):
token, _j, _e = draco_session.issue_token(
{"id": "personne-99", "username": "x", "display_name": "X"})
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 401
def test_me_draco_inerte_sans_secret(admin_client, monkeypatch):
token, _j, _e = draco_session.issue_token(USER)
monkeypatch.delenv("DRACO_SESSION_SECRET")
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 401
def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch):
token, jti, exp = draco_session.issue_token(USER)
draco_session.revoke(jti, exp)
r = admin_client.get("/api/sso/revocations")
assert r.status_code == 404 # clé non configurée
monkeypatch.setenv("DRACO_SSO_MACHINE_KEY", "cle-machine")
r = admin_client.get("/api/sso/revocations")
assert r.status_code == 401 # en-tête absent
r = admin_client.get("/api/sso/revocations",
headers={"X-Draco-Machine-Key": "cle-machine"})
assert r.status_code == 200
assert {"jti": jti, "exp": exp} in r.json()["revoked"]
+131
View File
@@ -0,0 +1,131 @@
"""Références croisées des notes — Fusion #215 (ACC-1.3).
Parseur d'URL profondes (formes figées de dracodev-commons
``contrats/liens-profonds.md``), stockage JSON par utilisateur et
endpoints ``/api/refs`` (création par collage d'URL, liste, retrait).
"""
import pytest
from backend.external_refs import (
add_ref,
delete_ref,
get_ref,
list_refs,
parse_deep_link,
)
@pytest.fixture()
def refs_env(tmp_path, monkeypatch):
monkeypatch.setenv("OBSIGATE_EXTERNAL_REFS_DIR", str(tmp_path / "refs"))
return tmp_path
# ------------------------------------------------------------ parseur
@pytest.mark.parametrize("url,attendu", [
("https://tiktak.dracodev.net/?task=42",
("tiktak", "task", "42", "TikTak · tâche 42")),
("https://tiktak.dracodev.net/?list=7",
("tiktak", "list", "7", "TikTak · liste 7")),
("https://og.dracodev.net/#file=home-bruno:Projets%2FAlpha.md",
("obsigate", "note", "home-bruno:Projets/Alpha.md", "ObsiGate · Alpha.md")),
("https://flowdeck.dracodev.net/pages/15",
("flowdeck", "page", "15", "Flowdeck · page 15")),
("https://flowdeck.dracodev.net/collections/3",
("flowdeck", "project", "3", "Flowdeck · projet 3")),
("https://git.dracodev.net/Projets/fusion-trois-apps/issues/5",
("kanbante", "card", "Projets/fusion-trois-apps#5", "Kanbante · fusion-trois-apps #5")),
])
def test_parse_formes_reconnues(url, attendu):
assert parse_deep_link(url) == attendu
@pytest.mark.parametrize("url", [
"https://example.com/?task=1",
"https://og.dracodev.net/#file=sansdeuxpoints",
"https://og.dracodev.net/",
"https://flowdeck.dracodev.net/autre/12",
"https://git.dracodev.net/Projets/fusion-trois-apps",
"pas une url",
"",
])
def test_parse_formes_refusees(url):
assert parse_deep_link(url) is None
# ------------------------------------------------------------ stockage
def test_stockage_cycle(refs_env):
ref = add_ref("alice", "TestVault", "Note A.md", "tiktak", "task", "42",
"https://tiktak.dracodev.net/?task=42", "TikTak · tâche 42")
assert ref["owner_type"] == "obsigate.note"
assert ref["owner_id"] == "TestVault:Note A.md"
assert get_ref("alice", ref["id"])["target_id"] == "42"
assert [r["id"] for r in list_refs("alice", "TestVault", "Note A.md")] == [ref["id"]]
assert list_refs("alice", "TestVault", "Autre.md") == []
assert list_refs("bob", "TestVault", "Note A.md") == [] # par utilisateur
removed = delete_ref("alice", ref["id"])
assert removed["id"] == ref["id"]
assert delete_ref("alice", ref["id"]) is None
assert list_refs("alice", "TestVault", "Note A.md") == []
# ------------------------------------------------------------ API
def test_api_cycle_complet(client, refs_env):
r = client.post("/api/refs", json={
"vault": "TestVault", "path": "Note A.md",
"url": "https://flowdeck.dracodev.net/collections/3",
})
assert r.status_code == 201, r.text
ref = r.json()
assert ref["target_app"] == "flowdeck"
assert ref["target_type"] == "project"
assert ref["label"] == "Flowdeck · projet 3"
r = client.post("/api/refs", json={
"vault": "TestVault", "path": "Note A.md",
"url": "https://tiktak.dracodev.net/?task=9", "label": "Ma tâche",
})
assert r.status_code == 201, r.text
assert r.json()["label"] == "Ma tâche"
r = client.get("/api/refs", params={"vault": "TestVault", "path": "Note A.md"})
assert r.status_code == 200
assert [x["target_app"] for x in r.json()["refs"]] == ["flowdeck", "tiktak"]
r = client.delete(f"/api/refs/{ref['id']}")
assert r.status_code == 200
r = client.get("/api/refs", params={"vault": "TestVault", "path": "Note A.md"})
assert [x["target_app"] for x in r.json()["refs"]] == ["tiktak"]
def test_api_url_inconnue_422(client, refs_env):
r = client.post("/api/refs", json={
"vault": "TestVault", "path": "Note A.md",
"url": "https://example.com/whatever",
})
assert r.status_code == 422
def test_api_delete_introuvable_404(client, refs_env):
r = client.delete("/api/refs/inexistant")
assert r.status_code == 404
def test_api_vault_sans_acces_403(admin_client, refs_env):
"""Auth activée : un utilisateur limité à TestVault reçoit 403 ailleurs."""
r = admin_client.post("/api/auth/login",
json={"username": "normaluser", "password": "normal123"})
assert r.status_code == 200, r.text
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
r = admin_client.get("/api/refs", headers=headers,
params={"vault": "VaultFantome", "path": "x.md"})
assert r.status_code == 403
r = admin_client.get("/api/refs", headers=headers,
params={"vault": "TestVault", "path": "x.md"})
assert r.status_code == 200
+69
View File
@@ -0,0 +1,69 @@
"""Tests de la recherche fédérée ObsiGate (fusion #68).
Fonctions pures de backend/recherche_cors.py uniquement : aucune base,
aucun serveur.
"""
from backend.recherche_cors import (
cors_headers_for,
is_dev_host,
is_prod_host,
origin_of,
preflight_headers,
sister_origins,
)
RESOLVED_DEV = {
"obsigate": "https://og-dev.dracodev.net",
"flowdeck": "https://flowdeck-dev.dracodev.net",
"tiktak": "https://tiktak-dev.dracodev.net",
"kanbante": "https://kanbante-dev.dracodev.net",
"agent": "https://agent-dev.dracodev.net",
"gitea": "https://git.dracodev.net",
}
def test_is_dev_host():
assert is_dev_host("og-dev.dracodev.net") is True
assert is_dev_host("og.dracodev.net") is False
assert is_dev_host("x-dev.dracodev.net.evil") is False
assert is_prod_host("og.dracodev.net") is True
assert is_prod_host("og-dev.dracodev.net") is False
def test_origin_of():
assert origin_of("https://og-dev.dracodev.net") == "https://og-dev.dracodev.net"
assert origin_of("https://og-dev.dracodev.net/chemin") == "https://og-dev.dracodev.net"
assert origin_of("{{URL_OBSIGATE}}") is None
def test_sister_origins_dev():
assert sister_origins(RESOLVED_DEV) == {
"https://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
}
def test_sister_origins_ajoute_env_de_la_requete():
assert "https://og-dev.dracodev.net" in sister_origins(
{}, request_host="og-dev.dracodev.net"
)
assert "https://og-dev.dracodev.net" not in sister_origins(
{}, request_host="og.dracodev.net"
)
def test_cors_headers():
headers = cors_headers_for("https://tiktak-dev.dracodev.net", RESOLVED_DEV)
assert headers["Access-Control-Allow-Origin"] == "https://tiktak-dev.dracodev.net"
assert headers["Access-Control-Allow-Credentials"] == "true"
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None
assert cors_headers_for("https://og.dracodev.net", RESOLVED_DEV) is None
def test_preflight_headers():
headers = preflight_headers("https://flowdeck-dev.dracodev.net", RESOLVED_DEV)
assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS"
assert preflight_headers("https://evil.example", RESOLVED_DEV) is None
+56
View File
@@ -72,3 +72,59 @@ def test_security_headers_present(client):
csp = resp.headers.get("content-security-policy", "")
assert "object-src 'none'" in csp
assert "frame-ancestors 'self'" in csp
def _csp_directives(csp: str) -> dict[str, list[str]]:
"""Découpe une politique CSP en ``{directive: [sources]}``."""
directives: dict[str, list[str]] = {}
for part in csp.split(";"):
tokens = part.split()
if tokens:
directives[tokens[0]] = tokens[1:]
return directives
def test_csp_frame_src_allows_agent_console(client, monkeypatch):
"""BUG-119 : le « mode console » du panneau Spark charge la console du
service d'agents (``https://agent.dracodev.net``) dans une iframe —
``frame-src`` doit autoriser cette origine, sinon le navigateur bloque
le chargement (« This content is blocked »). Les sources historiques
(``'self'`` et ``blob:``, visualiseur PDF) restent en place et aucune
autre origine n'est admise.
#223 : la configuration est neutralisée ici (défaut officiel) —
l'origine n'est plus figée dans le code, voir le test via config
ci-dessous."""
monkeypatch.delenv("DRACO_AGENT_URL", raising=False)
monkeypatch.delenv("AGENT_EVENEMENTS_URL", raising=False)
resp = client.get("/api/health")
csp = resp.headers.get("content-security-policy", "")
directives = _csp_directives(csp)
assert directives.get("frame-src") == [
"'self'",
"blob:",
"https://agent.dracodev.net",
]
assert "https://agent.dracodev.net" in directives.get("connect-src", [])
def test_csp_agent_origine_via_config(client, monkeypatch):
"""#223 : ``frame-src``/``connect-src`` suivent l'origine de l'agent
**configurée** — dérivée d'``AGENT_EVENEMENTS_URL`` (config réelle
du poste, noms en ``-dev`` en dev), ou de ``DRACO_AGENT_URL`` qui
prime. Aucun nom d'environnement n'est codé en dur."""
monkeypatch.delenv("DRACO_AGENT_URL", raising=False)
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
resp = client.get("/api/health")
directives = _csp_directives(resp.headers.get("content-security-policy", ""))
assert directives.get("frame-src") == [
"'self'", "blob:", "https://agent-dev.dracodev.net"]
assert "https://agent-dev.dracodev.net" in directives.get("connect-src", [])
assert "https://agent.dracodev.net" not in directives.get("connect-src", [])
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent.example.net")
resp = client.get("/api/health")
directives = _csp_directives(resp.headers.get("content-security-policy", ""))
assert directives.get("frame-src") == [
"'self'", "blob:", "https://agent.example.net"]
+3 -3
View File
@@ -2,7 +2,7 @@
Validates:
- Theme JSON structure validity
- All 15 built-in themes have required keys and valid CSS colors
- All 16 built-in themes have required keys and valid CSS colors
- High-contrast and sepia mode generators produce valid CSS vars
- Import/export round-trip
- AVAILABLE_MODES includes all 4 modes
@@ -80,9 +80,9 @@ class TestThemeStructure:
assert '_genSepia' in self.content
def test_theme_count(self):
"""Should have 15 built-in themes."""
"""Should have 16 built-in themes."""
names = _extract_theme_names(self.content)
assert len(names) == 15, f"Expected 15 themes, found {len(names)}: {names}"
assert len(names) == 16, f"Expected 16 themes, found {len(names)}: {names}"
def test_required_export_functions(self):
"""All required functions should be exported."""