Compare commits

...
Author SHA1 Message Date
bruno 603ba976e1 Merge pull request 'feat: vault DIR — exclusions par vault et lecture seule déclarable (vault Spark)' (#58) from spark-vault-dir into main
CI / lint (push) Successful in 1m36s
CI / security (push) Successful in 22s
CI / test (push) Successful in 3m52s
CI / build (push) Successful in 18s
CI / e2e (push) Successful in 16m36s
2026-11-03 16:24:37 -05:00
Spark ad1cea23ab release: version 2.83.0 (synchronisation complète)
CI / lint (pull_request) Successful in 1m24s
CI / security (pull_request) Successful in 21s
CI / test (pull_request) Successful in 3m50s
CI / build (pull_request) Successful in 14s
CI / e2e (pull_request) Successful in 16m45s
2026-11-03 21:00:59 +00:00
bruno b5b5a0e820 chore: version 2.83.0 (vault DIR exclusions + fix CI e2e)
CI / lint (pull_request) Successful in 1m35s
CI / security (pull_request) Successful in 37s
CI / test (pull_request) Failing after 3m32s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-11-03 14:09:13 -05:00
Spark 60666bf79d chore: relance CI après nettoyage du runner 2026-11-03 14:09:13 -05:00
Spark 099ffe7e8f chore: relance CI (runner dégradé, run 3161 gelé) 2026-11-03 14:09:12 -05:00
Spark bd5507d6ef feat: vault DIR — garde d'exclusion sur les lectures xlsx (vault Spark) 2026-11-03 14:09:12 -05:00
Spark 3ba8b7b667 feat: vault DIR — exclusions par vault et lecture seule déclarable (vault Spark) 2026-11-03 14:09:12 -05:00
bruno 52dcbf3607 Merge pull request 'fix(ci): e2e DinD par DNS réseau — fin du blocage 'Start ObsiGate'' (#60) from fix/ci-e2e-resseau-dns into main
CI / lint (push) Successful in 1m40s
CI / security (push) Successful in 35s
CI / test (push) Successful in 4m25s
CI / build (push) Successful in 23s
CI / e2e (push) Successful in 16m57s
2026-11-03 14:08:36 -05:00
bruno e28a537f79 fix(e2e): z-index barre résultats au-dessus de la barre DRACO + signal de boot du spec .ts
CI / lint (pull_request) Successful in 1m19s
CI / security (pull_request) Successful in 20s
CI / test (pull_request) Successful in 3m35s
CI / build (pull_request) Successful in 22s
CI / e2e (pull_request) Successful in 16m22s
- obsigate.spec.ts attendait encore le flag __OBSIGATE_BOOTED retiré (même
  cause que les 8 suites js, corrigée ici) ;
- .results-bar (z 50) était masquée par .draco-spark-flottant (z 65, même
  position bas-centre) → « subtree intercepts pointer events » sur
  #rb-next/#rb-case : 2 tests en timeout de clic. Bug d'UI réel (l'utilisateur
  ne peut pas cliquer les flèches pendant qu'Spark flotte), corrigé par
  z-index 66. Validé local : 2 passed en 10 s (au lieu de 2×60 s de timeout).
2026-11-03 11:17:35 -05:00
bruno b87ac72d78 ci(wip): runs-on ci-deepin — contournement régression extraction moby 29.x du runner VM (à retirer quand daemon VM réparé)
CI / security (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 1m39s
CI / test (pull_request) Successful in 3m36s
CI / build (pull_request) Successful in 14s
CI / e2e (pull_request) Failing after 30m53s
2026-11-03 09:59:18 -05:00
bruno ca367b6918 test(e2e): helpers ne dépendent plus du signal __OBSIGATE_BOOTED retiré (bfe0c75)
CI / security (pull_request) Failing after 2s
CI / lint (pull_request) Successful in 1m18s
CI / test (pull_request) Successful in 3m35s
CI / build (pull_request) Successful in 12s
CI / e2e (pull_request) Failing after 31m42s
bfe0c75 a retiré le splash et son signal de boot côté app sans adapter les
helpers des specs ; login() attendait window.__OBSIGATE_BOOTED → timeout
60 s × chaque test des 8 suites. Signal de rechange sans dépendance au
flag interne : document prêt ET premier .tree-item rendu (preuve que
l'init a lié données+handlers). Validé local : 14 passed sur les 3 suites
qui échouaient en CI (obsigate:ci v2.82.8, auth off, mêmes fixtures).
2026-11-03 09:26:42 -05:00
bruno 9037f2dd69 fix(ci): e2e joint l'app par DNS de réseau (DinD), plus de port publié ni curls sans timeout
CI / security (pull_request) Failing after 2s
CI / lint (pull_request) Successful in 1m35s
CI / test (pull_request) Successful in 3m47s
CI / build (pull_request) Successful in 15s
CI / e2e (pull_request) Failing after 30m52s
Le step 'Start ObsiGate' pendait : boucle de health curls sans --max-time
vers l'IP passerelle:2029 (injoignable/port fuité par un run antérieur) =
150 s de blocage par tentative. L'app est désormais rattachée au réseau du
job et jointe par nom (obsigate-e2e:8080), avec timeout partout et dump
des logs container à l'échec.
2026-11-03 08:44:08 -05:00
bruno 3a27fde176 Merge pull request 'fix: CI préexistante — imports coffres (ruff I001) + planchers pyasn1/pypdf' (#59) from fix-ci-main into main
CI / security (push) Failing after 1s
CI / lint (push) Successful in 1m37s
CI / test (push) Successful in 3m37s
CI / build (push) Successful in 10s
CI / e2e (push) Failing after 1h10m34s
2026-11-03 01:21:25 -05:00
Spark d3acae2bd8 fix: tests draco_barre — assertions alignées sur le code actuel (#222, #218)
CI / security (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 1m16s
CI / test (pull_request) Successful in 3m58s
CI / build (pull_request) Successful in 1m4s
CI / e2e (pull_request) Successful in 17m19s
2026-11-03 05:56:41 +00:00
Spark ad0a715a4c fix: resynchroniser les fichiers dérivés sur la version 2.82.8 (dérive préexistante) 2026-11-03 05:56:41 +00:00
Spark f7095f8057 fix: test unitaire frontend — recherche.js exempté d'import/export (fichier vendangé #68)
CI / security (pull_request) Failing after 1s
CI / lint (pull_request) Successful in 1m36s
CI / test (pull_request) Failing after 3m47s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-11-03 05:48:53 +00:00
Spark 67fbda60d3 fix: planchers de sécurité pip-audit — pypdf>=6.21.4 et pyasn1>=0.6.5
CI / security (pull_request) Successful in 19s
CI / lint (pull_request) Failing after 25s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-11-03 05:44:13 +00:00
Spark 5280b84d12 fix: tri des imports coffre_store dans routers/coffres (ruff I001) 2026-11-03 05:44:13 +00:00
bruno b42a81018c release: changelog 2.82.8
CI / security (push) Failing after 0s
CI / lint (push) Failing after 1m59s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-30 18:36:43 -04:00
bruno 5c85763de1 release: version 2.82.8
CI / security (push) Failing after 0s
CI / lint (push) Failing after 1m56s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-30 18:36:39 -04:00
bruno 2aacb60102 Merge pull request 'secrets: afficher/masquer + copier la valeur dans le panneau détail' (#57) from secrets-afficher-copier 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-30 18:36:07 -04:00
bruno c4247b8bbc secrets: afficher/masquer + copier la valeur, revelation gestionnaire auditee (og/COFFRE-contrat-distribution.md)
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-30 18:34:21 -04:00
bruno c29bafeccc secrets: afficher/masquer + copier la valeur, revelation gestionnaire auditee (og/test_coffre_secrets.py) 2026-10-30 18:34:18 -04:00
bruno fe4bed91d7 secrets: afficher/masquer + copier la valeur, revelation gestionnaire auditee (og/en.json) 2026-10-30 18:34:15 -04:00
bruno 3abf908ca4 secrets: afficher/masquer + copier la valeur, revelation gestionnaire auditee (og/fr.json) 2026-10-30 18:34:12 -04:00
bruno 9c8e8f54dc secrets: afficher/masquer + copier la valeur, revelation gestionnaire auditee (og/coffres.js) 2026-10-30 18:34:09 -04:00
bruno 1e59781d77 secrets: afficher/masquer + copier la valeur, revelation gestionnaire auditee (og/coffres.py) 2026-10-30 18:34:07 -04:00
bruno ead3393211 Merge pull request 'ObsiGate 2.82.7 : barre v0.1.11 — marque monochrome, anneau avatar' (#56) from barre-v0111-2-82-7 into main
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-29 23:10:14 -04:00
bruno 48beb692dc ObsiGate 2.82.7 : étoile Spark monochrome pour la marque de la barre
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-29 23:10:00 -04:00
bruno fe4b189697 ObsiGate 2.82.7 : barre v0.1.11, marque monochrome + anneau avatar (tests/test_draco_barre.py) 2026-10-29 23:09:59 -04:00
bruno c1e79d07e4 ObsiGate 2.82.7 : barre v0.1.11, marque monochrome + anneau avatar (CHANGELOG.md) 2026-10-29 23:09:57 -04:00
bruno 5de962b2c6 ObsiGate 2.82.7 : barre v0.1.11, marque monochrome + anneau avatar (VERSION) 2026-10-29 23:09:55 -04:00
bruno bc150bf225 ObsiGate 2.82.7 : barre v0.1.11, marque monochrome + anneau avatar (frontend/draco-barre.css) 2026-10-29 23:09:53 -04:00
bruno 31b8a86861 ObsiGate 2.82.7 : barre v0.1.11, marque monochrome + anneau avatar (frontend/recherche.html) 2026-10-29 23:09:52 -04:00
bruno dda60313f0 ObsiGate 2.82.7 : barre v0.1.11, marque monochrome + anneau avatar (frontend/index.html) 2026-10-29 23:09:50 -04:00
bruno fdf33c0f54 Merge pull request 'ObsiGate 2.82.6 : barre v0.1.10 — rouge Spark, marque logo, avatar flottant, Alertes icône' (#55) from barre-logos-2-82-6 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-29 22:56:31 -04:00
bruno 92bfd8f0ef ObsiGate 2.82.6 : avatar Spark flottant (commons v0.1.10)
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-29 22:56:13 -04:00
bruno 64973a578b ObsiGate 2.82.6 : barre v0.1.10 — rouge Spark, marque logo, avatar flottant, Alertes icône (tests/test_draco_barre.py) 2026-10-29 22:56:11 -04:00
bruno fb5414efbb ObsiGate 2.82.6 : barre v0.1.10 — rouge Spark, marque logo, avatar flottant, Alertes icône (CHANGELOG.md) 2026-10-29 22:56:09 -04:00
bruno 174cd1c3e7 ObsiGate 2.82.6 : barre v0.1.10 — rouge Spark, marque logo, avatar flottant, Alertes icône (VERSION) 2026-10-29 22:56:07 -04:00
bruno ee6748c261 ObsiGate 2.82.6 : barre v0.1.10 — rouge Spark, marque logo, avatar flottant, Alertes icône (frontend/draco-barre.css) 2026-10-29 22:56:06 -04:00
bruno dd0b12213f ObsiGate 2.82.6 : barre v0.1.10 — rouge Spark, marque logo, avatar flottant, Alertes icône (frontend/recherche.html) 2026-10-29 22:56:04 -04:00
bruno 6195f252d9 ObsiGate 2.82.6 : barre v0.1.10 — rouge Spark, marque logo, avatar flottant, Alertes icône (frontend/index.html) 2026-10-29 22:56:02 -04:00
bruno 397a60663c Merge pull request 'ObsiGate 2.82.5 : les 5 logos dans le menu et la barre commune' (#54) from barre-logos-2-82-5 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-29 22:22:58 -04:00
bruno d792714f4f ObsiGate 2.82.5 : logo fourni spark.png pour la barre et le menu
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-29 22:22:41 -04:00
bruno 65edbc9e6c ObsiGate 2.82.5 : logo fourni kanbante.png pour la barre et le menu 2026-10-29 22:22:39 -04:00
bruno 95df928035 ObsiGate 2.82.5 : logo fourni tiktak.svg pour la barre et le menu 2026-10-29 22:22:38 -04:00
bruno 78ac84e1cc ObsiGate 2.82.5 : logo fourni flowdeck.png pour la barre et le menu 2026-10-29 22:22:37 -04:00
bruno 550bcb5cc8 ObsiGate 2.82.5 : logo fourni obsigate.png pour la barre et le menu 2026-10-29 22:22:35 -04:00
bruno 44baa0a273 ObsiGate 2.82.5 : logos barre-apps v0.1.9 dans le menu et la barre (CHANGELOG.md) 2026-10-29 22:22:34 -04:00
bruno 78021d6549 ObsiGate 2.82.5 : logos barre-apps v0.1.9 dans le menu et la barre (VERSION) 2026-10-29 22:22:32 -04:00
bruno 72d8ce39d6 ObsiGate 2.82.5 : logos barre-apps v0.1.9 dans le menu et la barre (frontend/draco-barre.css) 2026-10-29 22:22:30 -04:00
bruno 14dd77094d ObsiGate 2.82.5 : logos barre-apps v0.1.9 dans le menu et la barre (frontend/draco-barre.js) 2026-10-29 22:22:29 -04:00
bruno 031879889c ObsiGate 2.82.5 : logos barre-apps v0.1.9 dans le menu et la barre (frontend/recherche.html) 2026-10-29 22:22:27 -04:00
bruno a5d2e0d085 ObsiGate 2.82.5 : logos barre-apps v0.1.9 dans le menu et la barre (frontend/index.html) 2026-10-29 22:22:25 -04:00
bruno 8403b4fa08 chore(release): v2.82.4 — CHANGELOG.md
CI / lint (push) Failing after 2s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 18:25:02 -04:00
bruno 757e32229d chore(release): v2.82.4 — docs/ROADMAP.md
CI / lint (push) Failing after 3s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 18:24:54 -04:00
bruno 4c995f4c65 chore(release): v2.82.4 — desktop/Cargo.lock
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 2m4s
2026-10-28 18:24:49 -04:00
bruno c950940e22 chore(release): v2.82.4 — desktop/Cargo.toml
CI / security (push) Failing after 2s
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 18:24:41 -04:00
bruno 858dfd22e1 chore(release): v2.82.4 — desktop/tauri.conf.json
CI / lint (push) Failing after 2s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 18:24:36 -04:00
bruno ab4a4dbd9d chore(release): v2.82.4 — package.json
CI / lint (push) Failing after 3s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 2m5s
2026-10-28 18:24:29 -04:00
bruno 0bc1aca9bc chore(release): v2.82.4 — VERSION
CI / security (push) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 18:24:24 -04:00
bruno 515727ab9b Merge pull request 'Retire l'ecran de chargement plein ecran au demarrage d'ObsiGate' (#53) from fix/retrait-splash-demarrage into main
CI / security (push) Failing after 0s
CI / lint (push) Failing after 1m51s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 18:22:24 -04:00
bruno 3f375bca2a Merge pull request 'Retire le texte de chantier theme rendu en haut de /recherche' (#52) from fix/recherche-texte-theme-parasite into main
CI / security (push) Failing after 1s
CI / lint (push) Failing after 1m53s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 18:22:09 -04:00
bruno 483af904af Test non-regression : aucun splash au demarrage, boot JS et indicateurs internes intacts
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) Failing after 0s
2026-10-28 16:44:13 -04:00
bruno bfe0c75106 Retire le signal de boot vers le splash supprime (DOMContentLoaded simplifie) 2026-10-28 16:44:11 -04:00
bruno 2a96ad3b1d Retire l'ecran de chargement plein ecran au demarrage (splash, CSS de masquage, script de pilotage) 2026-10-28 16:44:08 -04:00
bruno 12a29b29f6 Test non-regression : aucun texte brut rendu dans le head de /recherche
CI / security (pull_request) Successful in 1m57s
CI / lint (pull_request) Failing after 2m17s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 16:43:35 -04:00
bruno 8feab0188c Retire le texte de chantier theme rendu en haut de /recherche (lignes hors commentaire) 2026-10-28 16:43:31 -04:00
bruno fc44efabbb Changelog 2.82.3 : thème /recherche, CORS console (PR #49/#51)
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 16:22:51 -04:00
bruno 3ed09cbc0f Bump 2.82.3
CI / lint (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 0s
CI / e2e (push) Skipped
2026-10-28 16:22:48 -04:00
bruno a4f27bdbe7 Bump 2.82.3
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 16:22:44 -04:00
bruno 05d1903045 Bump 2.82.3
CI / lint (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 16:22:39 -04:00
bruno b5b4a00b75 Bump 2.82.3
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 16:22:35 -04:00
bruno 97f997840c Bump 2.82.3
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 0s
CI / e2e (push) Skipped
2026-10-28 16:22:31 -04:00
bruno 474851b3bb Bump 2.82.3
CI / lint (push) Failing after 2s
CI / security (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 16:22:26 -04:00
bruno 4dfa8fd74c Merge pull request 'CORS recherche : autoriser la console Spark (agent prod+dev)' (#51) from federation-recherche-console into main
CI / lint (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 16:17:07 -04:00
bruno e709310e70 Merge pull request 'recherche: appliquer le thème ObsiGate à la page /recherche (défaut 4)' (#49) from obsigate-recherche-theme into main
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 16:17:05 -04:00
bruno 4d0c8ad934 Tests CORS recherche: origines agent autorisees, tiers refuse
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / security (pull_request) Failing after 1s
CI / e2e (pull_request) Skipped
2026-10-28 16:12:15 -04:00
bruno b609e8913c CORS recherche: autoriser les origines agent (console Spark) prod+dev 2026-10-28 16:12:10 -04:00
bruno f10e3d12e6 recherche: tests du theme de la page (defaut 4)
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 1m57s
2026-10-28 15:59:18 -04:00
bruno 78c514c78b recherche: decliner la barre et les accents selon le theme (defaut 4) 2026-10-28 15:59:13 -04:00
bruno d20ae93b40 recherche: appliquer le theme ObsiGate (defaut 4) 2026-10-28 15:59:08 -04:00
bruno a885153a68 Bump ObsiGate 2.82.2 : changelog (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 15:07:30 -04:00
bruno 3edeb88c92 Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 0s
CI / e2e (push) Skipped
2026-10-28 15:07:26 -04:00
bruno 677b174f09 Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 15:07:23 -04:00
bruno ab3d0798c7 Bump ObsiGate 2.82.2 (alignement recherche console #68)
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 15:07:20 -04:00
bruno 332983c4d6 Bump ObsiGate 2.82.2 (alignement recherche console #68)
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 15:07:17 -04:00
bruno 2c2f23dc57 Bump ObsiGate 2.82.2 (alignement recherche console #68)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 15:07:14 -04:00
bruno 097accc44c Bump ObsiGate 2.82.2 (alignement recherche console #68)
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 15:07:10 -04:00
bruno 7ea5614093 Merge pull request 'recherche : onglet Console Spark sur GET /api/v1/recherche + barre /recherche re-vendangée (alignement)' (#48) from align-recherche-console into main
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 15:04:32 -04:00
bruno e4da00cf17 recherche: jeton URL_AGENT + connect-src agent pour la page /recherche
CI / lint (pull_request) Failing after 3s
CI / security (pull_request) Failing after 2s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 14:45:13 -04:00
bruno 03e8abf8c8 recherche: re-vendoring barre /recherche (5e onglet Console Spark) 2026-10-28 14:45:09 -04:00
bruno 6750cdf81a recherche: tests alignement console /api/v1/recherche 2026-10-28 14:45:06 -04:00
bruno ffc70e10b9 recherche: onglet Console Spark sur GET /api/v1/recherche (alignement) 2026-10-28 14:45:01 -04:00
bruno ed834a44b5 Version 2.82.1
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 14:27:58 -04:00
bruno 66312bba2d Version 2.82.1
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 14:27:54 -04:00
bruno 46572501cf Version 2.82.1
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 14:27:48 -04:00
bruno b9a570424b Version 2.82.1
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 14:27:38 -04:00
bruno c6c06a7db9 Version 2.82.1
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 0s
CI / e2e (push) Skipped
2026-10-28 14:27:35 -04:00
bruno f12f69124f Version 2.82.1
CI / lint (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 14:27:32 -04:00
bruno 504a349224 Version 2.82.1
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 14:27:29 -04:00
bruno d6549d1b86 Merge pull request 'Chantier 5 — Alertes en onglets + composant partagé + onglet Console Spark dans la barre' (#47) from alertes-onglets-5 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 14:26:02 -04:00
bruno 9c9828e9bd Merge pull request 'Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page' (#46) from recherche-onglets-ux-68 into main
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 14:25:53 -04:00
bruno 5b9fe5944b Merge pull request 'CORS recherche : allowlist soeurs prod+dev explicites (fusion #68/#69)' (#44) from cors-recherche-alertes-68-69 into main
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 14:25:28 -04:00
bruno 3a7b119b24 CHANGELOG 2.82.0 (coffre distribuables, PR #45)
CI / lint (push) Failing after 2s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 14:00:12 -04:00
bruno 5ad6b68fef Version 2.82.0 (ROADMAP)
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 2s
CI / e2e (push) Skipped
2026-10-28 14:00:07 -04:00
bruno 37e3ffd586 Version 2.82.0 (Cargo.lock)
CI / lint (push) Failing after 3s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 14:00:03 -04:00
bruno 36dacc55da Version 2.82.0 (Cargo.toml)
CI / lint (push) Failing after 3s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 13:59:58 -04:00
bruno 3180ef63ce Version 2.82.0 (tauri.conf.json)
CI / lint (push) Failing after 2s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 13:59:53 -04:00
bruno bdba956bd2 Version 2.82.0 (package.json)
CI / lint (push) Failing after 3s
CI / test (push) Skipped
CI / security (push) Failing after 1s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 13:59:49 -04:00
bruno 732af9de55 Version 2.82.0 (coffre distribuables, PR #45)
CI / lint (push) Failing after 3s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 1s
CI / e2e (push) Skipped
2026-10-28 13:59:45 -04:00
bruno 564f2a7b11 Merge pull request 'Coffre : endpoint de liste des secrets distribuables pour un consommateur (issue #43)' (#45) from coffre-distribuables into main
CI / lint (push) Failing after 4s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 13:57:16 -04:00
bruno 39df53e53a Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : tests mjs 17
CI / lint (pull_request) Failing after 1s
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 13:52:04 -04:00
bruno c2f3feec7e Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : styles alertes vendanges 2026-10-28 13:52:00 -04:00
bruno 3e042eed2d Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : module alertes vendange ui/alertes 2026-10-28 13:51:54 -04:00
bruno 183fd316ef Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : css barre vendangee commons 2026-10-28 13:51:50 -04:00
bruno 58e1c23592 Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : barre vendangee commons 2026-10-28 13:51:46 -04:00
bruno 3e7e53d2fc Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : onglet agent dans la barre 2026-10-28 13:51:40 -04:00
bruno 53b6f27fd2 Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page
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 13:50:50 -04:00
bruno 8f5e1f1b0c Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page 2026-10-28 13:50:46 -04:00
bruno 6eb679f234 Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page 2026-10-28 13:50:42 -04:00
bruno 1604245f94 Recherche partout (#68, chantier 2+3) : onglets, Tout voir sur place, focus, mise en page 2026-10-28 13:50:40 -04:00
bruno 5154afed01 Test : distribuables (octrois actifs, métadonnées seules, auth consommateur)
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 13:48:49 -04:00
bruno 8c40aba850 Coffre : endpoint GET /api/coffres/distribuables pour un consommateur (issue #43) 2026-10-28 13:48:46 -04:00
bruno b9a7824b28 Tests CORS recherche : prod+dev autorises, tiers refuse
CI / lint (pull_request) Failing after 0s
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 13:45:55 -04:00
bruno 883ededa8f CORS recherche (fusion #68/#69) : allowlist soeurs prod+dev explicites 2026-10-28 13:45:52 -04:00
bruno b9db438034 Bump 2.81.4 (programme Fusion #68/#69/#70)
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 13:01:28 -04:00
bruno 0273990196 Bump 2.81.4 (programme Fusion #68/#69/#70)
CI / lint (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 0s
CI / e2e (push) Skipped
2026-10-28 13:01:22 -04:00
bruno 782d2bea72 Bump 2.81.4 (programme Fusion #68/#69/#70)
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 1s
2026-10-28 13:01:17 -04:00
bruno f08883cb69 Bump 2.81.4 (programme Fusion #68/#69/#70)
CI / lint (push) Failing after 2s
CI / test (push) Skipped
CI / security (push) Failing after 1s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 13:01:13 -04:00
bruno 3150aeecaa Bump 2.81.4 (programme Fusion #68/#69/#70)
CI / lint (push) Failing after 3s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Failing after 0s
2026-10-28 13:01:09 -04:00
bruno c9c9a0dd6f Bump 2.81.4 (programme Fusion #68/#69/#70)
CI / lint (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / security (push) Failing after 1s
CI / e2e (push) Skipped
2026-10-28 13:01:05 -04:00
bruno 2283b72b2f Bump 2.81.4 (programme Fusion #68/#69/#70)
CI / lint (push) Failing after 1s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 13:01:01 -04:00
bruno 940a3b4534 Merge pull request 'Recherche partout (#68) : page de résultats unifiée + CORS bornée /api/search' (#42) from recherche-partout-68-rebase into main
CI / security (push) Failing after 1s
CI / lint (push) Failing after 2m4s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 12:53:37 -04:00
bruno 35a9c7ac80 Recherche partout (#68, rebase sur main post-#70 et post-#69)
CI / security (pull_request) Failing after 1s
CI / lint (pull_request) Failing after 2m12s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 12:53:20 -04:00
bruno 2e8240347f Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:18 -04:00
bruno d69ff4a6f9 Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:14 -04:00
bruno 4ea4934b3c Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:11 -04:00
bruno add9252b43 Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:09 -04:00
bruno 45d5745b54 Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:06 -04:00
bruno c92054fb5c Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:03 -04:00
bruno 2e8d94d0bb Recherche partout (#68, rebase sur main post-#70 et post-#69) 2026-10-28 12:53:00 -04:00
bruno bfbca9dce4 Merge pull request 'Fusion #69 : Alertes unifiées (panneau + pastille, agrégation à la demande)' (#41) from alertes-unifiees-69-rebase into main
CI / lint (push) Failing after 1s
CI / security (push) Failing after 1s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 12:50:50 -04:00
bruno 2c69eb71d7 Fusion #69 : alertes unifiées (rebase sur main post-#70)
CI / lint (pull_request) Failing after 1s
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:50:28 -04:00
bruno c50e0e5d0e Fusion #69 : alertes unifiées (rebase sur main post-#70) 2026-10-28 12:50:25 -04:00
bruno f2dcf8970e Fusion #69 : alertes unifiées (rebase sur main post-#70) 2026-10-28 12:50:21 -04:00
bruno e882612249 Fusion #69 : alertes unifiées (rebase sur main post-#70) 2026-10-28 12:50:16 -04:00
bruno 9c9a007419 Fusion #69 : alertes unifiées (rebase sur main post-#70) 2026-10-28 12:50:12 -04:00
bruno 56b70481e8 Merge pull request 'fusion #70 : retirer le bouton trois points, Console Spark au menu marque' (#38) from fusion-70-barre-console-spark into main
CI / lint (push) Failing after 2s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 12:39:59 -04:00
bruno 55351bc997 fusion #70 : vendanger CSS sans menu trois points
CI / lint (pull_request) Failing after 2s
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:02:41 -04:00
bruno 33e4d14b81 fusion #70 : vendanger barre sans trois points, Console Spark, fermeture/focus corriges 2026-10-28 12:02:37 -04:00
bruno 7901c7919d fusion #70 : retirer le bouton trois points et le relais menu de la barre 2026-10-28 12:02:32 -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
65 changed files with 4994 additions and 312 deletions
+10
View File
@@ -47,6 +47,16 @@ OBSIGATE_ADMIN_PASSWORD=chab30
# Ignored directories (séparés par des virgules)
# OBSIGATE_IGNORED_DIRS=.obsidian,.trash,.git,__pycache__,node_modules,.obsigate-backup
# Vaults DIR (DIR_N_NAME / DIR_N_PATH dans le compose) — options par vault :
# DIR_N_READONLY=true : lecture seule déclarée (mutations refusées
# en 403, actions d'écriture masquées dans l'UI)
# DIR_N_IGNORED_DIRS=.home,.venv : noms de dossiers cachés pour CE vault
# (toute profondeur, en plus du global)
# DIR_N_IGNORED_PATHS=Executeurs/*/data,Executeurs/*/.env
# : motifs fnmatch sur le chemin relatif dans
# le vault (un dossier qui matche cache son
# sous-arbre) — navigation, lecture, index
# Audit
# OBSIGATE_AUDIT_MAX_SIZE=10485760
+29 -17
View File
@@ -12,7 +12,7 @@ on:
jobs:
# ── Lint ──────────────────────────────────────────────────────────
lint:
runs-on: ubuntu-latest
runs-on: ci-deepin
steps:
- uses: actions/checkout@v4
@@ -100,7 +100,7 @@ jobs:
# ── Tests ─────────────────────────────────────────────────────────
test:
needs: lint
runs-on: ubuntu-latest
runs-on: ci-deepin
steps:
- uses: actions/checkout@v4
@@ -127,7 +127,7 @@ jobs:
# ── Security scan ─────────────────────────────────────────────────
security:
runs-on: ubuntu-latest
runs-on: ci-deepin
steps:
- uses: actions/checkout@v4
@@ -209,7 +209,7 @@ jobs:
# ── Docker build ──────────────────────────────────────────────────
build:
needs: test
runs-on: ubuntu-latest
runs-on: ci-deepin
steps:
- uses: actions/checkout@v4
@@ -252,7 +252,7 @@ jobs:
# ── E2E Tests (Playwright) ─────────────────────────────────────────
e2e:
needs: build
runs-on: ubuntu-latest
runs-on: ci-deepin
steps:
- uses: actions/checkout@v4
@@ -272,7 +272,13 @@ jobs:
- name: Start ObsiGate
run: |
docker rm -f obsigate-e2e 2>/dev/null || true
docker create --name obsigate-e2e -p 2029:8080 \
# DinD robuste : PAS de port publié (les ports 2029 fuités par des
# runs antérieurs font échouer « docker start », et l'IP passerelle
# du job n'est pas toujours joignable → boucle de health curls sans
# timeout qui pendaient 150 s chacun). À la place, l'app est
# rattachée au réseau du job et jointable par nom DNS embarqué.
JOBNET=$(docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$(hostname)" 2>/dev/null | awk '{print $1}')
docker create --name obsigate-e2e \
-v $(pwd)/test_vault:/vaults/TestVault \
-v $(pwd)/test_dir:/vaults/TestDir \
-e VAULT_1_NAME=TestVault \
@@ -288,17 +294,22 @@ jobs:
# fixtures avant le démarrage (l'indexeur scanne au démarrage).
docker cp test_vault/. obsigate-e2e:/vaults/TestVault
docker cp test_dir/. obsigate-e2e:/vaults/TestDir
if [ -n "$JOBNET" ]; then docker network connect "$JOBNET" obsigate-e2e || true; fi
docker start obsigate-e2e
# Le port publié est joignable via l'IP de la passerelle (localhost
# du job container ne voit pas le port publié sur l'hôte).
GW=$(ip route show default | awk '{print $3}' || echo 172.17.0.1)
echo "Gateway IP: $GW"
# Wait for health check
for i in $(seq 1 30); do
if curl -sf "http://$GW:2029/api/health"; then echo "Health OK"; break; fi
if [ -n "$JOBNET" ]; then
APP_URL="http://obsigate-e2e:8080"
else
echo "Réseau du job introuvable (JOBNET vide) — impossible de localiser l'app sans port publié"
exit 1
fi
echo "APP_URL: $APP_URL"
# --max-time : ne JAMAIS pendre sans timeout (bug d'origine : curls
# sans timeout = 150 s × 31 tentatives, job « bloqué »).
for i in $(seq 1 40); do
if curl -sf --max-time 3 "$APP_URL/api/health"; then echo "Health OK"; break; fi
sleep 1
done
curl -sf "http://$GW:2029/api/health" >/dev/null || { echo "App not reachable at $GW:2029"; exit 1; }
curl -sf --max-time 5 "$APP_URL/api/health" >/dev/null || { echo "App not reachable at $APP_URL"; docker logs --tail 40 obsigate-e2e; exit 1; }
# Les fixtures sont copiées via `docker cp` en root → rendre le vault
# inscriptible par l'utilisateur non-root de l'app (UID 1000), sinon
# toute création/édition de fichier renvoie 403 « Vault is read-only ».
@@ -306,9 +317,10 @@ jobs:
- name: Run E2E tests
run: |
GW=$(ip route show default | awk '{print $3}' || echo 172.17.0.1)
echo "Using BASE_URL=http://$GW:2029"
BASE_URL="http://$GW:2029" npx playwright test --project=chromium-desktop --reporter=list
BASE_URL="http://obsigate-e2e:8080"
docker exec obsigate-e2e true >/dev/null 2>&1 || { echo "obsigate-e2e n'est pas en marche"; exit 1; }
echo "Using BASE_URL=$BASE_URL"
BASE_URL="$BASE_URL" npx playwright test --project=chromium-desktop --reporter=list
- name: Upload test results
if: always()
+87 -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.81.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.83.0**.
---
@@ -14,6 +14,92 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.83.0] — 2026-11-03
- **Vaults DIR : exclusions par vault et lecture seule déclarable** (prépare le vault « Spark ») : trois nouvelles variables par vault DIR — `DIR_N_READONLY=true` (les mutations sont refusées en 403 « read_only » et l'arborescence n'affiche plus les actions d'écriture pour ce vault), `DIR_N_IGNORED_DIRS=.home,.venv,…` (noms de dossiers cachés à toute profondeur, en plus du global `IGNORED_DIRS`) et `DIR_N_IGNORED_PATHS=Executeurs/*/data,…` (motifs fnmatch sur le chemin relatif ; un dossier qui matche cache tout son sous-arbre). Les exclusions s'appliquent désormais **partout** — navigation (`/api/browse`), lecture (`/api/file`, `raw`, téléchargement : 404), liste des chemins et indexation — et plus seulement à l'indexeur. `/api/vaults` expose `readonly` pour chaque vault. Sans ces variables, comportement inchangé. Fiche : `docs/features/spark-vault-dir.md`. Tests : `tests/test_spark_vault_dir.py`.
---
## [2.82.8] — 2026-10-30
- **Panneau détail d'un secret : Afficher/Masquer + Copier la valeur** (PR #57) : la valeur reste masquée (••••••) par défaut ; Afficher la révèle en place, Masquer l'efface (effacée aussi dès que le détail change). « Copier la valeur » copie au presse-papiers sans afficher. Le bouton « Copier » existant ne copiait que la référence.
- **Nouvel endpoint `POST /api/coffres/{slug}/secrets/{nom}/reveler`** : session + gestion du coffre exigées (propriétaire, ou admin sur coffre d'instance) ; un lecteur par octroi utilisateur reçoit 403. Chaque révélation est auditée (`revelation_manuelle`) et met à jour la dernière utilisation. Ce n'est pas une distribution : aucun octroi créé, aucun consommateur ne reçoit la valeur.
- **Suppression de la « Copie manuelle à usage unique »** par consommateur temporaire : elle échouait systématiquement (aucun octroi pour le consommateur temporaire, donc 403 octroi_absent) ; « Copier la valeur » la remplace.
- **Tests** : `tests/test_coffre_secrets.py` 44/44 (5 nouveaux TestRevelation).
## [2.82.7] — 2026-10-29
- **Barre commune (dracodev-commons v0.1.11 vendangée)** : la marque de la barre n'est plus le logo rouge — c'est l'étoile Spark monochrome (`frontend/img/brand-spark.png`, servi en `/static/img/brand-spark.png`, 26 px), blanche sur barre foncée et noire sur barre claire : ObsiGate pose déjà `data-theme` sur `<html>` (sombre par défaut, mode persisté), donc la règle vendangée `[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }` s'applique directement et aucune surcharge propre n'est nécessaire. L'onglet Console Spark (et son entrée de menu, qui le clone) GARDE le logo rouge. Le bouton avatar Spark flottant bas-centre gagne un anneau rouge `#c00000` (bordure 3 px, `var(--agent)`), via le CSS vendangé.
- **Pages concernées** : la barre est rendue en ligne dans `frontend/index.html` et dans sa copie de `frontend/recherche.html` — les deux sont portées à la v0.1.11 ; `frontend/draco-barre.css` remplacé verbatim par la v0.1.11, `frontend/draco-barre.js` inchangé (identique à commons, lui-même inchangé en v0.1.11). Le reste de l'intégration est inchangé. Précision : sur `/recherche`, copie statique, le bouton flottant y est visuel seulement, comme au lot précédent — l'ouverture du panneau vit dans la SPA.
## [2.82.6] — 2026-10-29
- **Barre commune (dracodev-commons v0.1.10 vendangée)** : le rose Spark `#c2437e` disparaît de la barre — `--agent` passe au rouge Spark `#c00000` (échantillonné dans le logo Spark), l'onglet Console Spark passe à `--app #c00000`, et la marque ✦ devient le logo Spark (26 px). Le bouton « Spark » rose de la barre est supprimé, remplacé par le bouton avatar Spark flottant bas-centre (cercle personnage, `frontend/img/avatar-spark.png`, servi en `/static/img/avatar-spark.png`, `data-act="spark"` → panneau Spark de l'hôte, comme l'ancien bouton). Le bouton Alertes n'affiche plus que la cloche (pastille conservée), donc une barre plus étroite.
- **Pages concernées** : la barre est rendue en ligne dans `frontend/index.html` et dans sa copie de `frontend/recherche.html` — les deux sont portées à la v0.1.10 ; `frontend/draco-barre.css` remplacé verbatim par la v0.1.10, `frontend/draco-barre.js` inchangé (identique à commons v0.1.9, lui-même inchangé en v0.1.10). Le reste de l'intégration (URL, `DRACO_URLS`, actions, compteurs) est inchangé. Précision : sur `/recherche`, copie statique, le panneau Spark n'est pas rejoué (comme avant) — le bouton flottant y est visuel, l'ouverture du panneau vit dans la SPA.
## [2.82.5] — 2026-10-29
- **Barre commune (dracodev-commons v0.1.9 vendangée)** : les 5 tuiles des onglets — et donc du menu de la marque, qui les clone — sont désormais les logos fournis, tels quels, jamais redessinés : ObsiGate (arbre), Flowdeck (cartes), TikTak (logo officiel SVG), Kanbante (cycle vert), Console Spark (étoile rouge validée). Fichiers dans `frontend/img/logos/` (copie de `ui/barre-apps/logos/` de commons, PNG 180 px), servis en `/static/img/logos/` ; l'entrée « Console Spark » du menu clone l'icône de l'onglet agent (`draco-barre.js`) ; onglet agent passé à `--app #d21f2c`. Les glyphes texte ▤/▦/✓/▥/✦ disparaissent des onglets (marque ✦, bouton Spark et rappel d'en-tête inchangés).
- **Pages concernées** : la barre est rendue en ligne dans `frontend/index.html` et dans sa copie de `frontend/recherche.html` — les deux sont portées à la v0.1.9, `draco-barre.js` et `draco-barre.css` remplacés verbatim (les fichiers précédents étaient identiques à l'octet près à ceux de commons avant la v0.1.9, aucune adaptation locale perdue). Le reste de l'intégration (URL, `DRACO_URLS`, actions, compteurs) est inchangé.
## [2.82.4] — 2026-10-28
- **Recherche (PR #52)** : texte parasite du mécanisme de thème retiré du haut de la page `/recherche` (lignes 28-43 de `frontend/recherche.html`, hors commentaire).
- **Démarrage (PR #53)** : écran de chargement plein écran `#boot-splash` retiré au démarrage d'ObsiGate (CSS, div et script de pilotage), sans écran de remplacement.
## [2.82.3] — 2026-10-28
- **Recherche (PR #49)** : la page `/recherche` applique désormais le thème ObsiGate.
- **CORS recherche (PR #51)** : la recherche ObsiGate autorise désormais la console Spark (agent prod+dev) en inter-origines.
## [2.82.2] — 2026-10-28
- **Recherche (#68)** : l'onglet Console Spark de `/recherche` appelle désormais `GET /api/v1/recherche` (au lieu de `/api/search`) et reconstruit les liens profonds console (`?section=…&objet=…`, `disc=`, `activite=`) — les résultats console réels s'affichent enfin dans la page hôte ; barre de `/recherche` re-vendangée depuis `index.html` (5e onglet Console Spark) et `connect-src` CSP étendu à l'origine agent.
## [2.82.1] — 2026-10-28
### Modifié
- **CORS recherche/alertes (#44)** : allowlist explicite des 8 origines sœurs prod+dev, avec credentials, jamais `*` — chaque serveur n'autorisait que l'environnement résolu (prod OU dev, jamais les deux).
- **Recherche partout (#46)** : page `/recherche` réintégrée dans le gabarit applicatif, focus automatique, « Tout voir » restant sur la page, onglets Tout / ObsiGate / Flowdeck / TikTak / Kanbante / Console Spark.
- **Alertes unifiées (#47)** : onglets Tout / ObsiGate / Flowdeck / TikTak / Kanbante / Console Spark / Historique (actives sauf Historique = fermées), filtre texte, composant partagé, bouton Console Spark en 5e onglet de l'en-tête après Kanbante.
---
## [2.82.0] — 2026-10-28
### Ajouté
- **Coffre : secrets distribuables (#43)** : nouvel endpoint `GET /api/coffres/distribuables` — authentifié par jeton consommateur (la même vérification que `/distribuer`), il renvoie les métadonnées seules des secrets pour lesquels ce consommateur a un octroi actif (coffre actif, secret non expiré, octroi ni révoqué ni expiré, octrois coffre-wide inclus). Un consommateur sans octroi reçoit une liste vide. Jamais de valeur dans la réponse. Consommateur : la console Spark (agent-commun #14 / PR #15), pour la liste déroulante des clés de fournisseurs LLM.
## [2.81.4] — 2026-10-28
### Modifié
- **Barre (#70)** : bouton « ••• » retiré (menu par défaut supprimé), « Console Spark » ajoutée au menu de la marque, fermeture/focus corrigés.
- **Alertes unifiées (#69)** : panneau actives puis historique, par application, avec pastille ; agrégation à la demande, pas une fédération temps réel ; CSP connect-src sœurs.
- **Rechercher partout (#68)** : page `/recherche` unifiée par application, liens profonds dans l'environnement courant (dérivation `-dev` respectée), CORS bornée à `/api/search` ; la palette de la barre ouvre désormais `/recherche`.
## [2.81.3] — 2026-10-28
### Modifié
- **Barre d'apps** — vendange de `dracodev-commons` v0.1.6 : dérivation `-dev` par l'hôte courant, menu « ••• » par défaut, menu compte fonctionnel, spark sans dérivation inventée.
---
## [2.81.2] — 2026-10-28
### Modifié
- **Panneau Spark / console (PR #35)** — style console du panneau, statut « Connecté » intégré au sous-titre de l'en-tête (barre séparée supprimée), texte sous la zone de saisie supprimé, composer identique à la console (pilule, pièces jointes 4 max, textarea auto-extensible, micro dictée, envoi rond bleu), resynchronisation du domaine à chaque ouverture / changement de stockage / retour console, URL console avec `?domain=` ; la console honore `?domain=` valide (persisté avant chargement des discussions, PR agent-commun #7).
---
## [2.81.1] — 2026-10-28
---
## [2.81.0] — 2026-10-28
---
+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.81.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.83.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.83.0).
---
*Projet : ObsiGate | Version : 2.81.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.83.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.81.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.83.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.83.0).
---
*Project: ObsiGate | Version: 2.81.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.83.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.81.0
2.83.0
+43 -3
View File
@@ -97,6 +97,13 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
Also reads optional configuration:
- VAULT_N_ATTACHMENTS_PATH: relative path to attachments folder
- VAULT_N_SCAN_ATTACHMENTS: "true"/"false" to enable/disable scanning
- DIR_N_READONLY: "true"/"false" — vault DIR déclaré en lecture seule
(les mutations sont refusées en 403, l'UI masque les actions d'écriture)
- DIR_N_IGNORED_DIRS: noms de dossiers ignorés pour ce vault, séparés
par des virgules (toute profondeur, en plus du global IGNORED_DIRS)
- DIR_N_IGNORED_PATHS: motifs fnmatch sur le chemin relatif POSIX dans
le vault, séparés par des virgules (un dossier qui matche cache tout
son sous-arbre) — navigation, lecture et indexation les respectent
Returns:
Dict mapping vault names to configuration dicts with keys:
@@ -104,6 +111,9 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
- attachmentsPath: relative attachments folder (optional)
- scanAttachmentsOnStartup: boolean (default True)
- type: "VAULT" or "DIR"
- readonly: boolean (DIR only, default False)
- ignoredDirs: set of directory names (DIR only, default empty)
- ignoredPaths: list of fnmatch patterns (DIR only, default empty)
"""
vaults: dict[str, dict[str, Any]] = {}
n = 1
@@ -132,11 +142,22 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
if not name or not path:
break
readonly = os.environ.get(f"DIR_{n}_READONLY", "false").lower() == "true"
ignored_dirs = {
d.strip() for d in os.environ.get(f"DIR_{n}_IGNORED_DIRS", "").split(",") if d.strip()
}
ignored_paths = [
p.strip() for p in os.environ.get(f"DIR_{n}_IGNORED_PATHS", "").split(",") if p.strip()
]
vaults[name] = {
"path": path,
"attachmentsPath": None,
"scanAttachmentsOnStartup": False,
"type": "DIR"
"type": "DIR",
"readonly": readonly,
"ignoredDirs": ignored_dirs,
"ignoredPaths": ignored_paths,
}
n += 1
@@ -509,7 +530,8 @@ def _scan_vault(
Args:
vault_name: Display name of the vault.
vault_path: Absolute filesystem path to the vault root.
vault_cfg: Optional vault configuration dict (unused for indexing, kept for compatibility).
vault_cfg: Optional vault configuration dict — ses exclusions
(``ignoredDirs`` / ``ignoredPaths``) sont appliquées au scan.
previous_files: Optional ``{relative_path: file_info}`` snapshot from a
previous scan used for differential reuse.
@@ -529,6 +551,16 @@ def _scan_vault(
root_resolved = vault_root.resolve(strict=False)
# Exclusions propres au vault (DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS) :
# elles s'ajoutent au global IGNORED_DIRS, sans le modifier. Le cfg est
# celui passé par l'appelant — l'index global ne contient pas encore ce
# vault pendant son premier scan, chemin_exclu() (qui lit l'index) ne
# peut donc pas servir ici.
from backend.services.vaults import chemin_exclu_dans_config
cfg = vault_cfg or {}
ignored_names = IGNORED_DIRS | set(cfg.get("ignoredDirs") or [])
# BUG-032: walk without following symlinks and refuse any symlink that
# escapes the vault root, so external data can never be indexed/exposed.
for dirpath, dirnames, filenames in os.walk(vault_root, followlinks=False):
@@ -537,7 +569,11 @@ def _scan_vault(
# Prune ignored and symlinked directories in place (no recursion).
dirnames[:] = [
d for d in dirnames
if d not in IGNORED_DIRS and not (current_dir / d).is_symlink()
if d not in ignored_names
and not (current_dir / d).is_symlink()
and not chemin_exclu_dans_config(
cfg, str((current_dir / d).relative_to(vault_root)).replace("\\", "/")
)
]
for d in dirnames:
@@ -565,6 +601,10 @@ def _scan_vault(
rel_path_str = str(fpath.relative_to(vault_root)).replace("\\", "/")
# Exclusion propre au vault (motif de chemin, ex. un .env ciblé).
if chemin_exclu_dans_config(cfg, rel_path_str):
continue
ext = fpath.suffix.lower()
# Also match extensionless files named like Dockerfile, Makefile
basename_lower = fpath.name.lower()
+91 -2
View File
@@ -189,15 +189,25 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# #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
from backend.draco_config import agent_origine, origine, urls_publiques
agent = agent_origine()
# Fusion #69 : le panneau Alertes unifiées lit les API
# notifications des applications soeurs depuis cet hôte ;
# leurs origines rejoignent connect-src (même résolution
# draco_config que les liens de la barre : explicite >
# dérivation depuis l'hôte de l'agent > défaut officiel).
_soeurs69 = " ".join(sorted({
origine(v)
for k, v in urls_publiques().items()
if k in ("obsigate", "flowdeck", "tiktak", "kanbante")
} - {""}))
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
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}; "
f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent} {_soeurs69}; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"worker-src 'self' blob:; "
f"frame-src 'self' blob: {agent}; "
@@ -521,6 +531,15 @@ app.add_middleware(
allow_headers=["*"],
)
# Recherche partout (#68) : CORS bornée pour GET /api/search — ajoutée
# après le CORSMiddleware global donc exécutée avant lui (dernier
# ajouté = le plus externe) : les preflights OPTIONS sont répondus
# ici, et les réponses de /api/search reçoivent les en-têtes sœurs.
# La politique same-origin explicite du middleware global reste la
# règle par défaut pour tout le reste.
from backend.recherche_cors import RechercheCorsMiddleware
app.add_middleware(RechercheCorsMiddleware)
# Auth router
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
from backend.ai_routes import router as ai_router
@@ -940,6 +959,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, jeton {{URL_AGENT}} inclus pour l'onglet Console
Spark de la barre re-vendangée) et le nonce CSP frais ; la
réponse pose sa propre CSP (préservée par
SecurityHeadersMiddleware, qui ne la pose que si elle est
absente) dont le connect-src autorise les origines sœurs ET
l'origine de l'agent — sinon le navigateur bloquerait les
appels sœurs et l'appel console (onglet Console Spark,
GET /api/v1/recherche) 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"),
("{{URL_AGENT}}", "agent"),
):
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",
"agent")
} - {""}))
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."""
+202
View File
@@ -0,0 +1,202 @@
"""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 : les origines officielles **prod ET dev,
listées explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus
les origines de la console Spark** (``AGENT_ORIGINS`` : agent prod
ET dev — fédération de la recherche depuis la console,
2026-10-28), **plus**
celles de ``backend.draco_config`` (``urls_publiques()`` :
explicite > dérivation par l'hôte de l'agent > défaut officiel)
— jamais ``*``. Correctif 2026-10-28 (fusion #68/#69, matrice
constatée par Bruno) : l'ancienne allowlist ne contenait que
l'environnement de la configuration (plus celui de l'hôte de la
requête), jamais les deux à la fois — une page de l'autre
environnement voyait sa section afficher « Injoignable depuis
cet hôte (CORS ou réseau) » ;
- 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",
})
AGENT_ORIGINS = frozenset({
"https://agent.dracodev.net",
"https://agent-dev.dracodev.net",
})
# Clés de draco_config.urls_publiques() qui sont des applications sœurs.
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
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)
# Correctif 2026-10-28 : prod ET dev toujours — ``request_host``
# ne restreint plus l'allowlist (il ne servait qu'à ajouter
# l'environnement de la requête, désormais inclus d'office).
# ``resolved`` n'ajoute que des origines de configuration sans
# jamais retirer une origine officielle.
origins |= PROD_ORIGINS | DEV_ORIGINS | AGENT_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)
+10 -2
View File
@@ -22,10 +22,12 @@ httpx>=0.27.0
# l'utilisateur). #224 : la version préinstallée dans la toolcache du
# runner (6.19.0) est frappée par 11 CVE supplémentaires (dont
# CVE-2026-113786, CVE-2026-115572, CVE-2026-115585, CVE-2026-115580),
# corrigées au plus tard en 6.21.2 — le plancher doit être >= 6.21.2 :
# corrigées au plus tard en 6.21.2. La 6.21.2 est à son tour frappée
# (CVE-2026-119203 et suivantes, relevées par le job security sur main),
# corrigées en 6.21.4 — le plancher doit être >= 6.21.4 :
# en dessous, pip répond « already satisfied » pour la toolcache et
# n'aligne jamais (même piège que BUG-093).
pypdf>=6.21.2
pypdf>=6.21.4
pyotp>=2.10.0
segno>=1.5.0
webauthn==2.6.0
@@ -79,3 +81,9 @@ cryptography>=44.0
# Plancher de securite : msgpack est transitif, version 1.2.1 relevee par le job
# security (pip-audit, bloquant) — CVE-2026-114613, corrigee en 1.2.3.
msgpack>=1.2.3
# Plancher de sécurité : pyasn1 est transitive (python-jose/rsa), version
# 0.6.4 relevée par le job security (pip-audit, bloquant) —
# CVE-2026-119194 à CVE-2026-119197, corrigées en 0.6.5. Sans plancher
# explicite, pip répond « already satisfied » pour la toolcache et
# n'aligne jamais (même piège que BUG-093).
pyasn1>=0.6.5
+97
View File
@@ -12,6 +12,14 @@ Règles d'or, appliquées côté serveur à chaque requête :
- Seul ``POST /api/coffres/distribuer`` rend une valeur, et uniquement à
un consommateur authentifié par jeton (hash + jti, jamais la session
utilisateur), avec un octroi actif et un contexte obligatoire.
- Seule exception, voulue : ``POST /api/coffres/{slug}/secrets/{nom}/reveler``
rend la valeur au gestionnaire du coffre authentifié par sa session
(propriétaire, ou admin sur coffre d'instance — jamais un simple
lecteur par octroi utilisateur), pour l'affichage et la copie manuelle
dans le panneau détail. Chaque révélation est auditée
(``revelation_manuelle``) et met à jour la dernière utilisation ; la
valeur n'est jamais journalisée et le frontend l'efface dès qu'elle
est masquée ou que le détail change.
- Autorisations (spec §6) : le propriétaire seul sur son coffre
personnel ; l'admin gère les coffres d'instance, les consommateurs et
la KEK, jamais le coffre personnel d'autrui ; l'ACL « * » des vaults
@@ -64,6 +72,7 @@ from backend.coffre_store import (
create_secret,
delete_secret,
distribuer_valeur,
est_expire,
get_coffre,
get_coffre_by_id,
get_coffre_or_404,
@@ -495,6 +504,50 @@ async def api_distribuer(request: Request, body: dict = Body(...)):
}
@router.get("/api/coffres/distribuables")
async def api_distribuables(request: Request):
"""Secrets distribuables pour un consommateur (issue #43).
Authentification par jeton consommateur (la même que
/distribuer), jamais par session utilisateur : c'est le
serveur agent qui appelle, pour alimenter la liste déroulante
de ses fournisseurs LLM. Retour = métadonnées seules des
secrets pour lesquels ce consommateur a un octroi ACTIF
(coffre actif, secret non expiré, octroi ni révoqué ni
expiré, y compris les octrois coffre-wide). Un consommateur
sans octroi reçoit une liste vide — jamais d'erreur
révélatrice, jamais de valeur.
"""
_exiger_actives()
auth = request.headers.get("Authorization", "")
jeton = auth[7:] if auth.startswith("Bearer ") else ""
consommateur = verifier_jeton_consommateur(jeton)
if consommateur is None:
raise HTTPException(status_code=401,
detail="consommateur_invalide")
if consommateur["statut"] != "actif":
raise HTTPException(status_code=403,
detail="consommateur_revoque")
sortie = []
for coffre in list_coffres():
if coffre["statut"] != "actif":
continue
for secret in list_secrets(coffre["id"]):
if est_expire(secret.get("expiration")):
continue
statut, _octroi = statut_octroi_consommateur(
consommateur["id"], coffre["id"], secret["id"])
if statut != "ok":
continue
sortie.append({
"reference": f"coffre:{coffre['slug']}/{secret['nom']}",
"coffre": coffre["slug"],
"nom": secret["nom"],
"version": secret["version_courante"],
})
return sortie
# ---------------------------------------------------------------------------
# Un coffre, ses secrets, ses octrois, son journal
# ---------------------------------------------------------------------------
@@ -577,6 +630,50 @@ async def api_rotate_secret(
return _metadata_secret(coffre, secret)
@router.post("/api/coffres/{slug}/secrets/{nom}/reveler")
async def api_reveler_secret(
slug: str, nom: str, current_user=Depends(require_auth)
):
"""Révélation manuelle pour le panneau détail (afficher / copier).
Session utilisateur + gestion du coffre exigées : un lecteur par
octroi utilisateur voit les métadonnées mais ne révèle jamais la
valeur. Chaque appel est audité et touche la dernière utilisation.
"""
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
secret = _secret_or_404(coffre, nom)
try:
valeur = distribuer_valeur(coffre, secret)
except SecretExpireError:
_audit(_username(current_user), "revelation_refusee",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="secret_expire")
raise HTTPException(status_code=410, detail="secret_expire") from None
except KEKAbsenteError:
_audit(_username(current_user), "revelation_refusee",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="kek_absente")
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
_audit(_username(current_user), "revelation_refusee",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="crypto_indisponible")
raise HTTPException(status_code=503,
detail="crypto_indisponible") from None
toucher_utilisation(secret["id"])
_audit(_username(current_user), "revelation_manuelle",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="ok")
return {"reference": f"coffre:{coffre['slug']}/{secret['nom']}",
"valeur": valeur, "version": secret["version_courante"]}
@router.delete("/api/coffres/{slug}/secrets/{nom}")
async def api_delete_secret(
slug: str, nom: str, current_user=Depends(require_auth)
+12 -1
View File
@@ -44,7 +44,7 @@ from backend.schemas import (
from backend.services.files import read_raw_file
from backend.services.mutations import file_revision
from backend.services.paths import resolve_safe_path
from backend.services.vaults import browse_directory, get_vault_root
from backend.services.vaults import browse_directory, chemin_exclu, get_vault_root
from backend.share import list_shares
logger = logging.getLogger("obsigate")
@@ -171,6 +171,9 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
if chemin_exclu(vault_name, path):
raise HTTPException(status_code=404, detail=f"File not found: {path}")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, path)
@@ -243,6 +246,8 @@ def api_file_xlsx_dashboard(
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
if chemin_exclu(vault_name, path):
raise HTTPException(status_code=404, detail=f"File not found: {path}")
_vault_root = get_vault_root(vault_name)
file_path = resolve_safe_path(_vault_root, path)
if not file_path.is_file():
@@ -311,6 +316,9 @@ def api_file_xlsx_sheet(
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
if chemin_exclu(vault_name, path):
raise HTTPException(status_code=404, detail=f"File not found: {path}")
file_path = resolve_safe_path(Path(vault_data["path"]), path)
if not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
@@ -366,6 +374,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
if chemin_exclu(vault_name, path):
raise HTTPException(status_code=404, detail=f"File not found: {path}")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, path)
+1
View File
@@ -609,6 +609,7 @@ class VaultInfo(BaseModel):
file_count: int = Field(description="Number of indexed files")
tag_count: int = Field(description="Number of unique tags")
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
readonly: bool = Field(default=False, description="True when the vault is declared read-only (DIR_N_READONLY) — mutations are refused and the UI hides write actions")
class BookmarkToggleRequest(BaseModel):
+19 -1
View File
@@ -7,13 +7,30 @@ from typing import Any
from backend.services.errors import ServiceError
from backend.services.paths import resolve_safe_path
from backend.services.vaults import get_vault_root
from backend.services.vaults import chemin_exclu, get_vault_root
logger = logging.getLogger("obsigate.services.files")
def _ensure_not_excluded(vault_name: str, path: str) -> None:
"""Raise ``not_found`` (404) when *path* is excluded in this vault.
Un chemin exclu (DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS) est
indiscernable d'un chemin inexistant : même erreur, aucun détail
sur la règle qui l'a caché.
"""
if chemin_exclu(vault_name, path):
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
def read_raw_file(vault_name: str, path: str) -> dict[str, Any]:
"""Return the raw text content of a vault file (no redaction)."""
_ensure_not_excluded(vault_name, path)
root = get_vault_root(vault_name)
file_path = resolve_safe_path(root, path)
@@ -56,6 +73,7 @@ def read_file_text(
ServiceError: ``not_found`` (404), ``file_too_large`` (413) or a read
error (500).
"""
_ensure_not_excluded(vault_name, path)
root = get_vault_root(vault_name)
target = resolve_safe_path(root, path)
+29 -19
View File
@@ -69,8 +69,18 @@ def _rel(root: Path, path: Path) -> str:
return str(path.relative_to(root)).replace("\\", "/")
def _ensure_writable(root: Path) -> None:
"""Raise ``read_only`` (403) when the vault root is not writable."""
def _ensure_writable(root: Path, vault_name: str | None = None) -> None:
"""Raise ``read_only`` (403) when the vault must not be mutated.
Two sources, same error: the vault is **declared** read-only
(``DIR_N_READONLY``, via :func:`vault_readonly`) or its root is
not writable on the filesystem (``:ro`` mount).
"""
if vault_name is not None:
from backend.services.vaults import vault_readonly
if vault_readonly(vault_name):
raise ServiceError("Vault is read-only", code="read_only", status=403)
if not os.access(root, os.W_OK):
raise ServiceError("Vault is read-only", code="read_only", status=403)
@@ -139,7 +149,7 @@ def create_file(
``unsupported_extension`` (400) or ``already_exists`` (409).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
file_path = resolve_safe_path(root, path)
_validate_extension(file_path)
@@ -190,7 +200,7 @@ def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> d
``already_exists`` (409) when *exist_ok* is False.
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
dir_path = resolve_safe_path(root, path)
if dir_path.exists():
@@ -234,7 +244,7 @@ def edit_file(
ServiceError: ``not_found`` (404) or ``read_only`` (403).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
@@ -470,7 +480,7 @@ def edit_xlsx_cells(
reference or value.
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
@@ -592,7 +602,7 @@ def mutate_xlsx_structure(
— deletions are NOT recoverable from the viewer (only via the ``.bak``).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
@@ -1000,7 +1010,7 @@ def mutate_xlsx_style(
from openpyxl.utils.cell import range_boundaries
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
@@ -1255,7 +1265,7 @@ def save_csv_cells(
from backend.xlsx_reader import sniff_csv_delimiter
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
@@ -1341,7 +1351,7 @@ def append_to_file(
ServiceError: ``not_found`` (404) or ``read_only`` (403).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
@@ -1382,7 +1392,7 @@ def rename_file(vault_name: str, path: str, new_name: str) -> dict[str, Any]:
``unsupported_extension`` (400) or ``already_exists`` (409).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
old_path = resolve_safe_path(root, path)
if not old_path.exists() or not old_path.is_file():
@@ -1424,7 +1434,7 @@ def rename_directory(vault_name: str, path: str, new_name: str) -> dict[str, Any
``already_exists`` (409).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
old_path = resolve_safe_path(root, path)
if not old_path.exists() or not old_path.is_dir():
@@ -1467,7 +1477,7 @@ def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> d
``unsupported_extension`` (400) or ``already_exists`` (409).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
source = resolve_safe_path(root, source_path)
if not source.exists():
@@ -1546,7 +1556,7 @@ def duplicate_path(vault_name: str, source_path: str, destination_dir: str = "")
destination is inside the source directory.
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
source = resolve_safe_path(root, source_path)
if not source.exists():
@@ -1764,7 +1774,7 @@ def delete_file(vault_name: str, path: str, *, backup: bool = True) -> dict[str,
ServiceError: ``not_found`` (404) or ``read_only`` (403).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
@@ -1797,7 +1807,7 @@ def delete_directory(vault_name: str, path: str, *, recursive: bool = True) -> d
``invalid_arguments`` (400) when targeting the vault root.
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
dir_path = resolve_safe_path(root, path)
if not dir_path.exists() or not dir_path.is_dir():
@@ -1855,7 +1865,7 @@ def restore_backup(
or ``read_only`` (403).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
file_path = resolve_safe_path(root, path)
backup_dir = get_backup_dir(vault_name, path)
@@ -1931,7 +1941,7 @@ def save_raw_file(
Dict with ``success``, ``vault``, ``path``, and ``size``.
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
file_path = resolve_safe_path(root, path)
_validate_extension(file_path, allow_images=True, allow_docs=allow_docs)
@@ -1980,7 +1990,7 @@ def batch_upload_files(
and ``errors`` (list of error dicts).
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
_ensure_writable(root, vault_name)
clean_target = (target_dir or "").strip().strip("/\\")
uploaded: list[str] = []
+2 -1
View File
@@ -215,8 +215,9 @@ def list_paths(vault: str, limit: int = 5000) -> dict[str, Any]:
from datetime import datetime
from backend.indexer import get_vault_data, path_index
from backend.services.vaults import chemin_exclu
entries = path_index.get(vault, [])
entries = [e for e in path_index.get(vault, []) if not chemin_exclu(vault, e["path"])]
meta: dict[str, dict[str, Any]] = {}
data = get_vault_data(vault)
if data:
+71 -1
View File
@@ -7,6 +7,7 @@ Single source of truth consumed by both the REST routes (``/api/vaults``,
from __future__ import annotations
import fnmatch
from datetime import UTC
from pathlib import Path
from typing import Any
@@ -15,6 +16,68 @@ from backend.services.errors import ServiceError
from backend.services.paths import resolve_safe_path
def chemin_exclu_dans_config(config: dict[str, Any] | None, rel_path: str) -> bool:
"""True si *rel_path* est exclu par la config d'exclusions d'un vault.
Deux mécanismes, lus dans le dict ``config`` du vault (alimenté par
``DIR_N_IGNORED_DIRS`` / ``DIR_N_IGNORED_PATHS``, vaults DIR seulement) :
- ``ignoredDirs`` : le nom d'un segment du chemin (à toute profondeur)
figure dans l'ensemble — ex. ``.home`` cache ``a/.home/x`` ;
- ``ignoredPaths`` : motifs fnmatch testés contre le chemin relatif
POSIX **et chacun de ses préfixes** — un dossier qui matche cache
donc tout son sous-arbre, ex. ``Executeurs/*/data`` cache aussi
``Executeurs/Hermes/data/config.md``.
Tolérant : config absente/vide ou chemin vide → False.
"""
if not config or not rel_path:
return False
rel = str(rel_path).replace("\\", "/").strip("/")
if not rel:
return False
segments = rel.split("/")
ignored_dirs = config.get("ignoredDirs") or []
if any(seg in ignored_dirs for seg in segments):
return True
prefixes = ["/".join(segments[:i]) for i in range(1, len(segments) + 1)]
for pattern in config.get("ignoredPaths") or []:
pat = str(pattern).replace("\\", "/").strip("/")
if pat and any(fnmatch.fnmatchcase(prefix, pat) for prefix in prefixes):
return True
return False
def chemin_exclu(vault_name: str, rel_path: str) -> bool:
"""True si *rel_path* est exclu dans le vault *vault_name*.
Lit la config du vault via l'index (``get_vault_data``). Tolérant :
vault inconnu ou sans config d'exclusions → False.
"""
try:
from backend.indexer import get_vault_data
data = get_vault_data(vault_name)
except Exception:
return False
if not data:
return False
return chemin_exclu_dans_config(data.get("config") or {}, rel_path)
def vault_readonly(vault_name: str) -> bool:
"""True si le vault est déclaré en lecture seule (DIR_N_READONLY)."""
try:
from backend.indexer import get_vault_data
data = get_vault_data(vault_name)
except Exception:
return False
if not data:
return False
return bool((data.get("config") or {}).get("readonly", False))
def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]:
"""Return the vaults *user* may access, with summary metadata."""
from backend.auth.middleware import check_vault_access
@@ -29,6 +92,7 @@ def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]:
"file_count": len(data.get("files", [])),
"tag_count": len(data.get("tags", {})),
"type": data.get("config", {}).get("type", "VAULT"),
"readonly": bool(data.get("config", {}).get("readonly", False)),
})
return result
@@ -58,7 +122,7 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
root = get_vault_root(vault_name)
target = resolve_safe_path(root, path) if path else root.resolve()
if not target.exists():
if not target.exists() or (path and chemin_exclu(vault_name, path)):
raise ServiceError(
f"Path not found: {path}",
code="not_found",
@@ -74,12 +138,15 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
if hide_hidden and entry.name.startswith("."):
continue
rel = str(entry.relative_to(root)).replace("\\", "/")
if chemin_exclu(vault_name, rel):
continue
if entry.is_dir():
# Count only direct children (files and subdirs) for performance.
try:
file_count = sum(
1 for child in entry.iterdir()
if (not hide_hidden or not child.name.startswith("."))
and not chemin_exclu(vault_name, f"{rel}/{child.name}")
and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile"))
or child.is_dir())
)
@@ -200,6 +267,9 @@ def list_all_files(
stat = entry.stat()
rel_path = str(entry.relative_to(root)).replace("\\", "/")
if chemin_exclu(vault_name, rel_path):
continue
if recursive and dir_prefix:
try:
rel_to_dir = str(entry.parent.relative_to(dir_path)).replace("\\", "/")
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.81.0"
version = "2.83.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.81.0"
version = "2.83.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.81.0",
"version": "2.83.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+10
View File
@@ -73,6 +73,16 @@ configurée (sinon 503 `kek_absente`).
SSO : une session utilisateur, même admin, ne distribue jamais
(401 `consommateur_invalide`). Il n'existe pas d'endpoint de
distribution par session (voir §6).
- Révélation manuelle (panneau détail, hors distribution) : `POST
/api/coffres/{slug}/secrets/{nom}/reveler` rend la valeur au
gestionnaire du coffre authentifié par sa session (propriétaire,
ou admin sur coffre d'instance), pour l'afficher ou la copier.
Ce n'est pas une distribution : aucun octroi n'est créé, aucun
consommateur ne reçoit la valeur, chaque révélation est auditée
(`revelation_manuelle`) et met à jour la dernière utilisation.
Un lecteur par octroi utilisateur (métadonnées visibles) reçoit
403. L'ancienne copie manuelle par consommateur temporaire a
été supprimée (elle échouait : 403 octroi_absent).
- Ajouter une app = enregistrer un consommateur + créer des octrois,
**sans modification du coffre** et sans toucher à ses secrets.
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.81.0 | **Dernière mise à jour :** 2026-10-28
> **Version :** 2.83.0 | **Dernière mise à jour :** 2026-11-03
> **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)**
+79
View File
@@ -0,0 +1,79 @@
# Vault DIR « Spark » — exclusions par vault et lecture seule déclarable
## Objectif
Permettre d'exposer dans ObsiGate des répertoires techniques (code du
service d'agents, workspaces du compagnon, coquilles d'exécuteurs) sous un
vault `DIR` monté en lecture seule — le vault « Spark » — sans jamais
rendre visibles leurs zones sensibles (profils et clés des exécuteurs,
`.home` des workspaces, `.venv`, etc.).
Avant ce chantier, deux manques le bloquaient proprement :
1. Les exclusions n'existaient qu'au niveau de l'indexeur (`IGNORED_DIRS`,
global) : la navigation et la lecture montraient le disque tel quel.
2. La lecture seule d'un vault n'était ni déclarable ni affichée : les
menus contextuels proposaient renommer / déplacer / supprimer, qui
échouaient ensuite côté serveur (montage `:ro`).
## Contrat de configuration (vaults DIR seulement)
Lues par `load_vault_config()` (`backend/indexer.py`, boucle `DIR_N`),
rangées dans le dict `config` du vault ; les vaults `VAULT` ne sont pas
touchés et les défauts (vide / faux) conservent le comportement actuel :
| Variable | Forme | Effet |
|---|---|---|
| `DIR_N_READONLY` | `true` / `false` | Mutations refusées en 403 `read_only` ; `readonly: true` exposé par `/api/vaults` ; l'UI masque les actions d'écriture |
| `DIR_N_IGNORED_DIRS` | `.home,.venv` | Noms de dossiers cachés à toute profondeur, en plus du global |
| `DIR_N_IGNORED_PATHS` | `Executeurs/*/data,Executeurs/*/.env` | Motifs fnmatch sur le chemin relatif POSIX ; un dossier qui matche cache tout son sous-arbre |
Exemple visé pour le vault Spark :
```
DIR_1_NAME=Spark
DIR_1_PATH=/vaults/Spark
DIR_1_READONLY=true
DIR_1_IGNORED_DIRS=.home,.venv,__pycache__,node_modules,.git
DIR_1_IGNORED_PATHS=Executeurs/*/data,Executeurs/*/.env,*/compagnon.json
```
(le montage lui-même — volume `:ro`, arborescence assemblée sur l'hôte —
relève de la configuration de déploiement, pas d'ObsiGate.)
## Implémentation
- **Helper central** : `chemin_exclu(vault_name, rel_path)` et
`chemin_exclu_dans_config(config, rel_path)` dans
`backend/services/vaults.py` — segment présent dans `ignoredDirs`, ou
fnmatch du chemin ou d'un de ses préfixes contre `ignoredPaths`.
Tolérant si vault / config absents. `vault_readonly(vault_name)` au
même endroit.
- **Navigation** : `browse_directory` filtre les items exclus (et leurs
compteurs d'enfants) ; naviguer directement dans un chemin exclu
répond 404. `list_all_files` et `list_paths` filtrent aussi.
- **Lecture** : un chemin exclu est indiscernable d'un chemin inexistant
(404, sans détail sur la règle) — `read_raw_file` / `read_file_text`
(`backend/services/files.py`), route `GET /api/file/{vault}` et route
de téléchargement (`backend/routers/files_read.py`).
- **Indexation** : le scan (`_scan_vault`, qui reçoit le `vault_cfg`)
élague les dossiers exclus et saute les fichiers exclus, en plus du
global `IGNORED_DIRS` inchangé.
- **Lecture seule** : `_ensure_writable(root, vault_name)` dans
`backend/services/mutations.py` refuse d'abord sur la déclaration
(`DIR_N_READONLY`), avant le garde filesystem existant — toutes les
mutations du service (créer, éditer, renommer, déplacer, supprimer,
upload, xlsx/csv) passent par ce point.
- **API / UI** : `VaultInfo.readonly` (`backend/schemas.py`) rempli par
`list_accessible_vaults` ; côté frontend, `vaultIsReadonly(nom)` dans
`frontend/js/sidebar.js` alimente `ContextMenuManager`,
`attachTreeItemActionButton` et `attachTreeItemLongPress` pour le
vault et ses dossiers / fichiers (aucun autre fichier JS touché).
## Tests
`tests/test_spark_vault_dir.py` : parsing des trois variables, helper
`chemin_exclu` (noms et motifs), navigation filtrée, lectures exclues en
404 (vue fichier, raw, téléchargement), fichier non exclu lisible,
indexation filtrée, mutation refusée en 403 sur vault readonly et
`/api/vaults` exposant `readonly=true`.
+58
View File
@@ -0,0 +1,58 @@
/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables
* d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */
.draco-alertes-overlay {
position: fixed; inset: 0; z-index: 30000;
display: none; align-items: flex-start; justify-content: center;
padding: 8vh 16px 16px;
background: rgb(0 0 0 / .55); overflow-y: auto;
}
.draco-alertes-box {
width: 720px; max-width: 100%;
background: var(--bg-primary, #1f1f1f);
color: var(--text, #eceee9);
border: 1px solid var(--border, #333);
border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5);
padding: 16px 18px 18px;
}
.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.draco-alertes-head h2 { margin: 0; font-size: 18px; }
.draco-alertes-close {
border: 1px solid var(--border, #333); background: transparent;
color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer;
}
.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; }
.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; }
.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; }
.draco-alertes-item {
border: 1px solid var(--border, #2a2a2a); border-radius: 10px;
padding: 9px 12px; margin: 6px 0;
}
.draco-alertes-item.is-active { border-color: #e8762c; }
.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; }
.draco-alertes-title:hover { text-decoration: underline; }
.draco-alertes-detail {
font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
.draco-alertes-empty a { color: inherit; }
/* Chantier 5 — onglets + filtre (mêmes variables d'hôte, mêmes replis). */
.draco-alertes-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 12px; }
.draco-alertes-tab {
border: 1px solid var(--border, #333); background: transparent;
color: inherit; border-radius: 999px; padding: 5px 11px;
cursor: pointer; font-size: 13px; line-height: 1.3;
}
.draco-alertes-tab[aria-selected="true"] { background: #e8762c; border-color: #e8762c; color: #fff; }
.draco-alertes-tab:focus-visible { outline: 2px solid #e8762c; outline-offset: 2px; }
.draco-alertes-filtre { display: flex; gap: 8px; align-items: center; margin: 0 0 4px; }
.draco-alertes-filtre label { font-size: 13px; color: var(--text-dim, #999); }
.draco-alertes-filtre input {
flex: 1; min-width: 0; background: transparent; color: inherit;
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 10px;
font-size: 13px;
}
.draco-alertes-compte { font-size: 12.5px; color: var(--text-dim, #999); margin: 4px 0 10px; min-height: 1em; }
.draco-alertes-compte:empty { display: none; }
.draco-alertes-item.is-error { border-color: #c2437e; }
+749
View File
@@ -0,0 +1,749 @@
/* Alertes unifiées dracodev — Fusion #69 (chantier 2) + chantier 5
* (onglets + composant partagé + onglet Console Spark).
*
* SOURCE CANONIQUE : Projets/dracodev-commons, ui/alertes/. Les copies
* dans les 4 hôtes (TikTak, Kanbante, Flowdeck, ObsiGate) sont des
* vendoring byte-identiques de ce fichier — ne pas les modifier sur
* place, toute évolution passe par commons puis re-vendoring.
*
* Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
* ouverture du panneau (et au chargement, pour la pastille), le module
* interroge les API de notifications réellement exposées par chaque
* application, avec les identifiants du navigateur (credentials:
* "include"). Aucune alerte n'est inventée : une source injoignable
* ou non authentifiée affiche son état réel par section (une section
* en échec n'empêche jamais de consulter les autres), jamais du vide
* déguisé. En attendant le correctif CORS des allowlists (autre
* chantier), une section inter-origines peut afficher cet état
* d'erreur : c'est un état d'erreur transitoire, pas un comportement
* normal — aucun état « Injoignable » systématique n'est présenté
* comme normal.
*
* Sources réelles (audit #69, 2026-10-28, inchangé) :
* - Flowdeck : GET {base}/api/notifications?limit=50
* -> { notifications: [{id, ntype, title, message, url, is_read,
* created_at}], unread } ; active = is_read = 0 ; lien = base + url.
* - TikTak : GET {base}/api/notifications
* -> [{id, kind, title, body, task_id, is_read, created_at}] ;
* active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon
* base + "/notifications".
* - Kanbante : GET {base}/api/events?limit=100
* -> { events: [{repo_full_name, issue_number, event_type,
* from_column, to_column, created_at}] } ; AUCUN état lu/non lu
* n'existe dans Kanbante, donc tout y est historique et rien n'y
* est compté comme actif ; lien = base + "/board/" + repo.
* - ObsiGate : AUCUNE notification entrante consultable. Ses
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets
* masqués), pas un journal lisible par l'utilisateur. L'onglet
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
* - Console Spark : AUCUN endpoint d'alertes console n'est publié
* sur agent-commun main au moment de ce chantier (autre chantier
* en cours). L'onglet Console Spark affiche donc un état vide
* HONNÊTE (« source indisponible », lien vers la console) et ne
* fabrique aucune alerte. Branchement prévu, sans deviner d'URL :
* poser window.DRACO_ALERTES_CONSOLE_URL (ou
* DRACO_BAR_CONFIG.consoleAlertsUrl) vers l'endpoint livré par
* l'autre chantier ; forme attendue : tableau, ou
* {alerts:[...]} / {items:[...]}, chaque alerte portant title,
* detail|message|context, date|created_at, active (booléen) ou
* status/state (pause budget, accès requis, en attente, fermée…),
* url|link. Les missions en pause budget / accès requis et les
* approbations en attente y apparaîtront avec leurs états réels.
*
* Résolution des bases (même ordre que barre-apps v0.1.6) :
* 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
* 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
* 3. href rendu de l'onglet .draco-apptab[data-app] (l'onglet
* « Console Spark » ajouté par le chantier 5 porte data-app
* "agent", donc la base agent suit le même ordre) ;
* 4. défaut officiel (production).
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
* lit aussi window.DRACO_URLS (injecté par son backend, clé "agent"
* incluse).
* Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
* que sur un hôte -dev, exactement comme la barre.
*
* Présentation (identique partout, imposée par ce seul fichier) :
* champs par alerte = application, titre, date, état, lien profond
* (environnement -dev respecté via la résolution ci-dessus) ;
* ordre = date décroissante dans chaque onglet (sans date en
* dernier) ; format de date = toLocaleString("fr-FR"), « sans date »
* si absente ou illisible ; libellés d'état affichés = « Active »,
* « Lue », ou le libellé d'événement Kanbante (ex. « Fermée »),
* ou l'état réel console quand la source existe. Le type brut
* Kanbante (« moved »…) n'est plus affiché : il dupliquait le
* libellé d'état en anglais.
* Onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
* Console Spark, Historique — dans cet ordre. Tout et chaque onglet
* par application ne montrent QUE les alertes actives ; Historique
* ne montre QUE les alertes fermées (lues / événements Kanbante /
* console fermées). Le filtre texte (titre + contexte) s'applique
* APRÈS le périmètre de l'onglet courant : il ne mélange jamais
* actifs et historique. L'onglet courant est dans l'URL
* (#alertes=<onglet>, partageable, retour arrière via pushState).
*
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
* pour la pastille (repli : mise à jour directe du badge).
* Insertion des données : textContent uniquement, jamais innerHTML
* avec une valeur venue du serveur.
*/
(function (global) {
"use strict";
var DEFAULT_URLS = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
agent: "https://agent.dracodev.net/"
};
var DEV_URLS = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark"
};
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
var ALL_APPS = ["obsigate", "flowdeck", "tiktak", "kanbante", "console"];
var TABS = ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"];
var TAB_LABELS = {
tout: "Tout", obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante",
console: "Console Spark", historique: "Historique"
};
var EVENT_LABELS = {
created: "créée", moved: "déplacée", closed: "fermée",
reopened: "rouverte", commented: "commentée"
};
var CONSOLE_ACTIVE_STATUSES = [
"active", "pending", "pause", "pause_budget", "acces_requis",
"en_attente", "en attente", "requise", "awaiting"
];
var CONSOLE_CLOSED_STATUSES = [
"closed", "resolved", "resolue", "lue", "terminee", "done", "fermee"
];
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
function cleanUrl(u) {
if (!u || typeof u !== "string") return null;
if (u.indexOf("{{") !== -1) return null;
if (!u.replace(/\s/g, "")) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
}
function joinUrl(base, path) {
var b = withSlash(base);
if (!path) return b;
if (/^https?:\/\//.test(path)) return path;
return b + String(path).replace(/^\//, "");
}
function parseDate(s) {
if (!s) return null;
var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1
? String(s).replace(" ", "T") + "Z" : s);
return isNaN(t.getTime()) ? null : t;
}
function fmtDate(s) {
var t = parseDate(s);
if (!t) return "";
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
}
function capitalize(s) {
s = String(s || "");
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
/* Résolution des bases. `env` permet les tests sans navigateur :
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls, consoleUrl }. */
function resolveUrls(env) {
env = env || {};
var hostname = env.hostname;
if (hostname === undefined) {
try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; }
}
var dev = isDevHost(hostname);
var urls = {};
ALL_APPS.forEach(function (key) {
var app = key === "console" ? "agent" : key;
var out = key === "console" ? "agent" : key;
var u = cleanUrl(env.barUrls && (env.barUrls[key] || env.barUrls[app]))
|| cleanUrl(env.configUrls && (env.configUrls[key] || env.configUrls[app]));
if (!u && dev) u = DEV_URLS[app] || null;
if (!u) {
var href = null;
if (env.tabHrefs && (env.tabHrefs[key] || env.tabHrefs[app])) href = env.tabHrefs[key] || env.tabHrefs[app];
else if (typeof document !== "undefined" && document.querySelector) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
href = tab ? tab.getAttribute("href") : null;
}
u = cleanUrl(href);
}
if (!u && env.dracoUrls && (env.dracoUrls[key] || env.dracoUrls[app])) u = cleanUrl(env.dracoUrls[key] || env.dracoUrls[app]);
if (!u) {
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
try {
if (global.DRACO_URLS && (global.DRACO_URLS[key] || global.DRACO_URLS[app])) u = cleanUrl(global.DRACO_URLS[key] || global.DRACO_URLS[app]);
} catch (e) { /* rien */ }
}
if (!u) u = DEFAULT_URLS[app];
urls[out] = withSlash(u);
if (key === "console") urls.console = withSlash(u);
});
return urls;
}
function browserEnv() {
var cfg = global.DRACO_BAR_CONFIG || {};
var bar = global.DRACO_BAR || {};
var tabs = {};
try {
if (typeof document !== "undefined" && document.querySelectorAll) {
var nodes = document.querySelectorAll(".draco-apptab[data-app]");
for (var i = 0; i < nodes.length; i++) {
tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href");
}
}
} catch (e) { /* rien */ }
return {
barUrls: bar.urls || null, configUrls: cfg.urls || null,
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null,
consoleUrl: null
};
}
/* URL de la source console, si l'hôte l'a configurée. AUCUNE URL
* n'est devinée : sans configuration, la source est indisponible
* (état vide honnête, voir renderCurrent). */
function consoleSourceUrl(env) {
env = env || {};
if (env.consoleUrl) return env.consoleUrl;
try {
if (global.DRACO_ALERTES_CONSOLE_URL) return global.DRACO_ALERTES_CONSOLE_URL;
var cfg = global.DRACO_BAR_CONFIG || {};
if (cfg.consoleAlertsUrl) return cfg.consoleAlertsUrl;
} catch (e) { /* rien */ }
return null;
}
function normalizeFlowdeck(base, payload) {
var list = (payload && payload.notifications) || [];
return list.map(function (n) {
return {
app: "flowdeck", id: n.id, title: n.title || "(sans titre)",
detail: n.message || "", date: n.created_at || "",
active: !n.is_read, state: n.is_read ? "lue" : "active",
kind: n.ntype || "", url: joinUrl(base, n.url || "")
};
});
}
function normalizeTiktak(base, payload) {
var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || [];
return list.map(function (n) {
return {
app: "tiktak", id: n.id, title: n.title || "(sans titre)",
detail: n.body || "", date: n.created_at || "",
active: !n.is_read, state: n.is_read ? "lue" : "active",
kind: n.kind || "",
url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications")
};
});
}
function normalizeKanbante(base, payload) {
var list = (payload && payload.events) || [];
return list.map(function (e) {
var label = EVENT_LABELS[e.event_type] || e.event_type || "événement";
var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || "");
return {
app: "kanbante", id: e.id,
title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label,
detail: detail, date: e.created_at || "",
active: false, state: label, kind: e.event_type || "",
url: joinUrl(base, "board/" + (e.repo_full_name || ""))
};
});
}
function normalizeConsole(base, payload) {
var list = Array.isArray(payload) ? payload
: (payload && (payload.alerts || payload.items)) || [];
return list.map(function (n) {
var status = String(n.status || "").toLowerCase().replace(/_/g, " ").trim();
var stateRaw = String(n.state || status || "");
var active;
if (typeof n.active === "boolean") active = n.active;
else if (status && CONSOLE_ACTIVE_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = true;
else if (status && CONSOLE_CLOSED_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = false;
else if (/pause|acc[eè]s|attente/.test(stateRaw.toLowerCase())) active = true;
else if (/ferm|résol|resol|termin/.test(stateRaw.toLowerCase())) active = false;
else active = true; /* source réelle, statut inconnu : on l'expose en active */
return {
app: "console", id: n.id, title: n.title || "(sans titre)",
detail: n.detail || n.message || n.context || "", date: n.date || n.created_at || "",
active: active, state: stateRaw || (active ? "en attente" : "fermée"),
kind: n.kind || n.type || "",
url: n.url || n.link ? joinUrl(base, n.url || n.link) : withSlash(base)
};
});
}
/* Libellé d'état AFFICHÉ, uniforme partout (les valeurs brutes de
* normalize* sont inchangées, pour compatibilité des tests #69). */
function displayState(a) {
if (a.active) return "Active";
if (a.app === "kanbante") return capitalize(a.state);
if (a.app === "console") return capitalize(a.state);
return "Lue";
}
function metaLine(a) {
var date = fmtDate(a.date) || "sans date";
/* Kanbante : le type brut (« moved ») dupliquait le libellé
* d'état ; il n'est plus affiché. Les autres apps gardent leur
* type quand il apporte une information. */
if (a.app === "kanbante") return APP_LABELS[a.app] + " · " + date + " · " + displayState(a);
var kind = a.kind ? " · " + a.kind : "";
return APP_LABELS[a.app] + kind + " · " + date + " · " + displayState(a);
}
/* Actives d'abord, puis date décroissante (sans date en dernier).
* Dans un onglet, le périmètre est déjà homogène (que des actives
* ou que des fermées) : l'ordre effectif est donc la date décroissante. */
function sortAlerts(list) {
return list.slice().sort(function (a, b) {
if (a.active !== b.active) return a.active ? -1 : 1;
var da = parseDate(a.date), db = parseDate(b.date);
if (da && db) return db - da;
if (da) return -1;
if (db) return 1;
return 0;
});
}
function countActive(list) {
var n = 0;
list.forEach(function (a) { if (a.active) n++; });
return n;
}
function allItems(results) {
var all = [];
ALL_APPS.forEach(function (app) {
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
});
return all;
}
/* Périmètre d'un onglet. Retour : { items, errors, consoleMissing }.
* Tout et onglets par application = actives seulement ; Historique =
* fermées seulement. Les erreurs de section sont remontées dans
* chaque onglet concerné (jamais silencieusement avalées). */
function scopeAlerts(all, tab, results) {
results = results || {};
var items = [], errors = [];
function collect(app, pred) {
if (results[app] && results[app].error) errors.push({ app: app, error: results[app].error });
all.forEach(function (a) { if (a.app === app && pred(a)) items.push(a); });
}
if (tab === "historique") {
ALL_APPS.forEach(function (app) { collect(app, function (a) { return !a.active; }); });
} else if (tab === "tout") {
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
} else if (ALL_APPS.indexOf(tab) !== -1) {
collect(tab, function (a) { return a.active; });
} else {
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
}
return {
items: sortAlerts(items), errors: errors,
consoleMissing: !!(results.console && results.console.sourceMissing)
};
}
function countByTab(all, results) {
var counts = {};
TABS.forEach(function (tab) { counts[tab] = scopeAlerts(all, tab, results).items.length; });
return counts;
}
/* Filtre texte, appliqué APRÈS le périmètre de l'onglet : titre ou
* contexte, insensible à la casse, sans jamais mélanger actifs et
* historique (le périmètre est déjà fixé par scopeAlerts). */
function filterAlerts(list, q) {
q = String(q || "").trim().toLowerCase();
if (!q) return list.slice();
return list.filter(function (a) {
return (String(a.title || "").toLowerCase().indexOf(q) !== -1)
|| (String(a.detail || "").toLowerCase().indexOf(q) !== -1);
});
}
/* Onglet depuis le hash d'URL : "#alertes=historique" -> "historique",
* "#alertes" -> "tout", autre -> null. */
function parseAlertTab(hash) {
if (!hash) return null;
var h = String(hash).replace(/^#/, "");
if (h === "alertes") return "tout";
var m = /^alertes=([a-z]+)$/.exec(h);
if (m && TABS.indexOf(m[1]) !== -1) return m[1];
return null;
}
function hashForTab(tab) {
if (TABS.indexOf(tab) === -1) tab = "tout";
return "#alertes=" + tab;
}
/* ---- Rendu (navigateur seulement) ---- */
var panel = null, listEl = null, tabsEl = null, filterEl = null,
countEl = null, badgeTimer = null;
var currentTab = "tout", currentData = null, currentFilter = "";
var panelPushed = false, closingViaBack = false, suppressHash = false;
function el(tag, cls, text) {
var d = document.createElement(tag);
if (cls) d.className = cls;
if (text !== undefined && text !== null) d.textContent = text;
return d;
}
function ensurePanel() {
if (panel) return panel;
var ov = el("div", "draco-alertes-overlay");
ov.setAttribute("role", "dialog");
ov.setAttribute("aria-label", "Alertes unifiées");
var box = el("div", "draco-alertes-box");
var head = el("div", "draco-alertes-head");
head.appendChild(el("h2", "", "Alertes unifiées"));
var close = el("button", "draco-alertes-close", "✕");
close.setAttribute("aria-label", "Fermer");
close.addEventListener("click", function () { closePanel(); });
head.appendChild(close);
box.appendChild(head);
box.appendChild(el("p", "draco-alertes-note",
"Agrégation à la demande, pas une fédération temps réel : " +
"les alertes sont relues dans chaque application à l'ouverture. " +
"Une section en échec n'empêche pas de consulter les autres."));
tabsEl = el("div", "draco-alertes-tabs");
tabsEl.setAttribute("role", "tablist");
tabsEl.setAttribute("aria-label", "Alertes par application");
tabsEl.addEventListener("keydown", onTabsKeydown);
box.appendChild(tabsEl);
var filtre = el("div", "draco-alertes-filtre");
var lbl = el("label", "", "Filtrer");
lbl.setAttribute("for", "draco-alertes-filtre-input");
filtre.appendChild(lbl);
filterEl = el("input", "");
filterEl.type = "search";
filterEl.id = "draco-alertes-filtre-input";
filterEl.setAttribute("placeholder", "Filtrer par titre ou contexte, dans cet onglet…");
filterEl.setAttribute("aria-label", "Filtrer les alertes de l'onglet courant");
filterEl.addEventListener("input", function () {
currentFilter = filterEl.value;
renderCurrent();
});
filtre.appendChild(filterEl);
box.appendChild(filtre);
countEl = el("p", "draco-alertes-compte", "");
box.appendChild(countEl);
listEl = el("div", "draco-alertes-list");
listEl.setAttribute("role", "tabpanel");
box.appendChild(listEl);
ov.appendChild(box);
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && panel && panel.style.display !== "none") closePanel();
});
document.body.appendChild(ov);
panel = ov;
return ov;
}
function closePanel() {
if (!panel) return;
panel.style.display = "none";
if (panelPushed) {
panelPushed = false;
closingViaBack = true;
try { history.back(); } catch (e) { closingViaBack = false; }
return;
}
try {
var h = parseAlertTab(window.location.hash);
if (h) {
suppressHash = true;
history.replaceState(null, "", window.location.pathname + window.location.search);
suppressHash = false;
}
} catch (e) { /* rien */ }
}
function onHashChange() {
if (suppressHash) return;
if (closingViaBack) { closingViaBack = false; return; }
var tab = parseAlertTab(window.location.hash);
if (tab) {
if (!panel || panel.style.display === "none") openPanel(tab);
else setTab(tab, false);
} else if (panel && panel.style.display !== "none") {
panelPushed = false;
panel.style.display = "none";
}
}
function onTabsKeydown(ev) {
var keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
if (keys.indexOf(ev.key) === -1) return;
var btns = tabsEl.querySelectorAll(".draco-alertes-tab");
var idx = TABS.indexOf(currentTab);
if (ev.key === "ArrowLeft") idx = (idx + TABS.length - 1) % TABS.length;
else if (ev.key === "ArrowRight") idx = (idx + 1) % TABS.length;
else if (ev.key === "Home") idx = 0;
else if (ev.key === "End") idx = TABS.length - 1;
ev.preventDefault();
setTab(TABS[idx], true);
if (btns[idx] && btns[idx].focus) btns[idx].focus();
}
function renderTabs() {
if (!tabsEl || !currentData) return;
tabsEl.textContent = "";
var all = allItems(currentData.results);
var counts = countByTab(all, currentData.results);
TABS.forEach(function (tab) {
var b = el("button", "draco-alertes-tab",
TAB_LABELS[tab] + " (" + counts[tab] + ")");
b.type = "button";
b.setAttribute("role", "tab");
b.setAttribute("aria-selected", tab === currentTab ? "true" : "false");
b.tabIndex = tab === currentTab ? 0 : -1;
b.addEventListener("click", function () { setTab(tab, true); });
tabsEl.appendChild(b);
});
}
function renderRow(a) {
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
var main = el("div", "draco-alertes-main");
var link = el("a", "draco-alertes-title", a.title);
link.href = a.url;
main.appendChild(link);
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
main.appendChild(el("div", "draco-alertes-meta", metaLine(a)));
row.appendChild(main);
return row;
}
function renderErrorRow(app, msg) {
var row = el("div", "draco-alertes-item is-error");
var main = el("div", "draco-alertes-main");
main.appendChild(el("div", "draco-alertes-title", APP_LABELS[app] + " — section en échec"));
main.appendChild(el("div", "draco-alertes-detail", msg));
main.appendChild(el("div", "draco-alertes-meta",
APP_LABELS[app] + " · section en échec — les autres sections restent consultables"));
row.appendChild(main);
return row;
}
function renderCurrent() {
if (!listEl || !currentData) return;
renderTabs();
var scope = scopeAlerts(allItems(currentData.results), currentTab, currentData.results);
var filtered = filterAlerts(scope.items, currentFilter);
var q = String(currentFilter || "").trim();
if (countEl) {
countEl.textContent = q
? filtered.length + " résultat(s) sur " + scope.items.length + " dans cet onglet — filtre « " + q + " »"
: "";
}
listEl.textContent = "";
/* Onglet Console sans source : état vide honnête, jamais d'alertes
* inventées, avec le lien profond vers la console. */
if (currentTab === "console" && scope.consoleMissing) {
var sec = el("section", "draco-alertes-section");
sec.appendChild(el("h3", "", "Console Spark (0)"));
sec.appendChild(el("p", "draco-alertes-empty",
"Aucune alerte console — source indisponible : l'endpoint " +
"d'alertes console (agent-commun, autre chantier) n'est pas " +
"encore publié. Les missions en pause budget / accès requis " +
"et les approbations en attente apparaîtront ici, avec leurs " +
"états réels, dès sa livraison."));
var link = el("div", "draco-alertes-empty");
var a = el("a", "", "Ouvrir la Console Spark");
a.href = currentData.urls.agent || currentData.urls.console || "#";
link.appendChild(a);
sec.appendChild(link);
listEl.appendChild(sec);
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
return;
}
if (currentTab === "obsigate" && !scope.items.length && !scope.errors.length) {
var secOg = el("section", "draco-alertes-section");
secOg.appendChild(el("h3", "", "ObsiGate (0)"));
secOg.appendChild(el("p", "draco-alertes-empty",
"ObsiGate n'expose aucune notification entrante consultable " +
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
var linkOg = el("div", "draco-alertes-empty");
var aOg = el("a", "", "Ouvrir ObsiGate");
aOg.href = currentData.urls.obsigate;
linkOg.appendChild(aOg);
secOg.appendChild(linkOg);
listEl.appendChild(secOg);
return;
}
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
if (!scope.items.length && !scope.errors.length) {
var empty = currentTab === "historique"
? "Aucune alerte fermée dans les applications joignables."
: currentTab === "tout"
? "Aucune alerte active dans les applications joignables."
: "Aucune alerte active pour " + TAB_LABELS[currentTab] + ".";
if (currentTab === "console") empty = "Aucune alerte console.";
listEl.appendChild(el("p", "draco-alertes-empty", empty));
return;
}
if (q && !filtered.length) {
listEl.appendChild(el("p", "draco-alertes-empty",
"Aucune alerte dans cet onglet pour ce filtre."));
return;
}
filtered.forEach(function (a) { listEl.appendChild(renderRow(a)); });
}
function setTab(tab, push) {
if (TABS.indexOf(tab) === -1) tab = "tout";
currentTab = tab;
renderCurrent();
if (push) {
try {
history.pushState(null, "", hashForTab(tab));
panelPushed = true;
} catch (e) { /* URL partageable : repli silencieux */ }
}
}
function fetchJson(url, timeoutMs) {
var ctrl = null, timer = null;
try {
if (typeof AbortController !== "undefined") {
ctrl = new AbortController();
timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000);
}
} catch (e) { ctrl = null; }
var opts = { credentials: "include" };
if (ctrl) opts.signal = ctrl.signal;
return fetch(url, opts).then(function (r) {
if (timer) clearTimeout(timer);
if (!r.ok) {
var err = new Error("HTTP " + r.status);
err.status = r.status;
throw err;
}
return r.json();
}).catch(function (e) {
if (timer) clearTimeout(timer);
throw e;
});
}
function loadAll() {
var urls = resolveUrls(browserEnv());
var consoleUrl = consoleSourceUrl(browserEnv());
var jobs = {
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
tiktak: fetchJson(urls.tiktak + "api/notifications")
.then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }),
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
};
if (consoleUrl) {
var agentBase = urls.agent || urls.console;
jobs.console = fetchJson(consoleUrl)
.then(function (p) { return { items: normalizeConsole(agentBase, p) }; });
}
var keys = Object.keys(jobs);
return Promise.all(keys.map(function (k) {
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
var msg = (e && e.status === 401)
? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau."
: "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ").";
return [k, { items: [], error: msg }];
});
})).then(function (pairs) {
var results = { obsigate: { items: [] } };
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
if (!consoleUrl) results.console = { items: [], sourceMissing: true };
return { urls: urls, results: results };
});
}
function setBadge(n) {
try {
if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") {
global.DRACO_BAR.setCounts({ alertes: n });
return;
}
} catch (e) { /* repli ci-dessous */ }
try {
document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) {
b.textContent = n;
b.hidden = !(n > 0);
});
} catch (e) { /* rien */ }
}
function refreshBadge() {
return loadAll().then(function (r) {
var n = countActive(allItems(r.results));
setBadge(n);
return n;
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
}
function openPanel(initialTab) {
ensurePanel();
panel.style.display = "flex";
var tab = initialTab || parseAlertTab(window.location.hash) || "tout";
currentTab = tab;
currentFilter = "";
if (filterEl) filterEl.value = "";
listEl.textContent = "";
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
if (tabsEl) tabsEl.textContent = "";
loadAll().then(function (r) {
currentData = r;
/* Ouverture par le bouton (pas de hash) : l'onglet entre dans
* l'URL pour être partageable ; ouverture par lien partagé : le
* hash est déjà le bon, on ne pousse pas d'entrée en double. */
var fromHash = parseAlertTab(window.location.hash);
if (!fromHash) {
try { history.pushState(null, "", hashForTab(tab)); panelPushed = true; } catch (e) { /* rien */ }
}
renderCurrent();
setBadge(countActive(allItems(r.results)));
}).catch(function () {
currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } };
renderCurrent();
});
}
var api = {
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
_test: {
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
normalizeConsole: normalizeConsole, consoleSourceUrl: consoleSourceUrl,
sortAlerts: sortAlerts, countActive: countActive,
filterAlerts: filterAlerts, scopeAlerts: scopeAlerts,
countByTab: countByTab, parseAlertTab: parseAlertTab,
hashForTab: hashForTab, displayState: displayState,
metaLine: metaLine, fmtDate: fmtDate,
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS,
TABS: TABS, TAB_LABELS: TAB_LABELS, APP_LABELS: APP_LABELS
}
};
global.DRACO_ALERTES = api;
if (typeof module !== "undefined" && module.exports) module.exports = api._test;
/* Pastille au chargement (après les scripts defer de la barre) ;
* un lien partagé #alertes=<onglet> ouvre directement le panneau
* sur cet onglet. */
if (typeof document !== "undefined" && document.addEventListener) {
document.addEventListener("DOMContentLoaded", function () {
if (badgeTimer) clearTimeout(badgeTimer);
badgeTimer = setTimeout(refreshBadge, 300);
var tab = parseAlertTab(window.location.hash);
if (tab) setTimeout(function () { openPanel(tab); }, 350);
});
window.addEventListener("hashchange", onHashChange);
}
})(typeof window !== "undefined" ? window : globalThis);
+76 -17
View File
@@ -2,7 +2,16 @@
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.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
v0.1.6 : menu compte (construit par le JS, mêmes jetons que le
menu des applications) ; l'avatar est un vrai <button>.
Correctif fusion #70 (SANS bump) : bouton trois points et menu par
défaut supprimés ; le menu de la marque gagne « Console Spark ».
v0.1.10 : rose --agent → rouge Spark #c00000 ; marque = logo Spark ;
bouton Spark supprimé (remplacé par l'avatar flottant bas-centre) ;
Alertes icône seule.
v0.1.11 : marque = étoile Spark monochrome (blanche barre foncée,
noire barre claire) ; anneau rouge #c00000 autour de l'avatar flottant.
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. */
@@ -10,7 +19,7 @@
.draco-bar {
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
--agent: #c2437e; --danger: #cf4444;
--agent: #c00000; --danger: #cf4444;
position: relative;
height: 60px; box-sizing: border-box;
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
@@ -32,7 +41,16 @@
/* 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; }
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
.draco-brand-mark { line-height: 0; }
/* Marque monochrome v0.1.11 : étoile blanche extraite du logo rouge,
fond transparent — noire sur barre claire (filtre), blanche sur
barre foncée (filtre annulé par les règles thème ci-dessous). */
.draco-brand-mark img { width: 26px; height: 26px; object-fit: cover; display: block; filter: brightness(0); }
@media (prefers-color-scheme: dark) {
.draco-bar .draco-brand-mark img { filter: none; }
}
[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }
/* Menu des applications — construit par le JS depuis les onglets */
.draco-apps-menu {
@@ -42,6 +60,29 @@
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-apps-menu[hidden] { display: none; }
/* Menu compte (v0.1.6) — même boîte que le menu des apps, ancré à
* droite ; ses items réutilisent .draco-apps-item. */
.draco-compte-menu {
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
min-width: 248px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-compte-menu[hidden] { display: none; }
.draco-menu-item {
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
padding: 8px 10px; border: 0; border-radius: 10px;
background: transparent; color: var(--ink); font: inherit; text-align: left;
text-decoration: none; cursor: pointer;
}
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
.draco-compte-nom {
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
border-bottom: 1px solid var(--line); margin-bottom: 6px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.draco-apps-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 10px;
@@ -66,6 +107,13 @@
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
}
/* Icônes logos (2026-10-29, corrigé le même jour) : l'image du logo
* fourni, telle quelle, remplit la tuile 30 px — jamais de glyphe
* texte, jamais de redessin. Le fond de la tuile est transparent :
* les logos PNG portent déjà leur fond (dégradé), le SVG TikTak
* reste sur le fond de la barre. */
.draco-apptab-ico { background: transparent; }
.draco-apptab-ico img { width: 30px; height: 30px; display: block; border-radius: 9px; object-fit: cover; }
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
@@ -103,18 +151,23 @@
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
.draco-alertes {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
position: relative; display: flex; align-items: center; height: 38px;
padding: 0 9px; border-radius: 10px; border: 1px solid var(--line);
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
}
/* Spark */
.draco-spark {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
/* Bouton avatar Spark flottant — bas-centre, ouvre le panneau Spark
de l'hôte via data-act="spark" (délégation globale existante).
La console Spark ne l'inclut pas : son fragment est propre. */
.draco-spark-flottant {
position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%);
z-index: 65; width: 64px; height: 64px; padding: 0; border: 0;
border-radius: 50%; background: transparent; cursor: pointer;
box-shadow: 0 8px 28px rgb(0 0 0 / .45);
}
.draco-spark .draco-badge { border-color: var(--agent); }
.draco-spark-flottant img { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; display: block; box-sizing: border-box; border: 3px solid var(--agent); }
.draco-spark-flottant:hover { transform: translateX(-50%) scale(1.05); }
.draco-spark-flottant:focus-visible { outline: 2px solid var(--agent); outline-offset: 3px; }
.draco-icon-btn {
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
@@ -122,8 +175,10 @@
}
.draco-icon-btn:hover { background: var(--surface-2); }
.draco-avatar {
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
background: #2f6fed; color: #fff; font-weight: 700;
width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%;
display: grid; place-items: center; flex: none;
background: #2f6fed; color: #fff; font: inherit; font-weight: 700;
cursor: pointer;
}
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
@@ -139,7 +194,7 @@
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
}
@media (max-width: 1200px) {
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
.draco-search-txt, .draco-search kbd { display: none; }
.draco-search { width: 38px; padding: 0; justify-content: center; }
.draco-bar { grid-template-columns: auto auto 1fr; }
.draco-bar-droite { justify-content: flex-end; }
@@ -147,8 +202,8 @@
@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
avatar. Le rail, la recherche et les domaines disparaissent :
chaque app garde ses contrôles natifs (barre
basse, en-tête) — un seul hamburger visible, celui de l'app.
Le changement d'app passe par le menu du ✦ (v0.1.3).
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
@@ -162,5 +217,9 @@
.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; }
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
/* Chantier 5 : sur la console elle-même (data-current="agent",
programme console #71), c'est l'onglet Console Spark qui reste
visible en mobile — les 4 hôtes gardent leur propre courant. */
.draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
}
+471 -42
View File
@@ -1,5 +1,14 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
* v0.1.3 : menu des applications ouvert par la marque ✦).
* v0.1.3 : menu des applications ouvert par la marque ✦ ;
* v0.1.5 : noms publics par configuration — aucun nom public d'un
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
* la publication est séparée) : dérivation -dev par l'hôte courant,
* menu trois points par défaut et menu compte fonctionnels, voir ci-dessous ;
* correctif fusion #70 (SANS bump de version, publication séparée) :
* bouton trois points et son menu par défaut SUPPRIMÉS, « Console Spark »
* ajoutée au menu de la marque, fermeture/focus corrigés (voir
* « Menu des applications », « Focus » et « Écouteurs globaux »).
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
@@ -10,19 +19,205 @@
* current: "tiktak", // app courante
* domain: "pro", // domaine actif
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* user: "Bruno", // nom affiché (v0.1.6) ;
* // ou { display_name: "Bruno", username: "bruno" }
* urls: { // bases des applications
* obsigate: "https://og.dracodev.net/",
* flowdeck: "https://flowdeck.dracodev.net/",
* tiktak: "https://tiktak.dracodev.net/",
* kanbante: "https://kanbante.dracodev.net/",
* agent: "https://agent.dracodev.net/",
* spark: "https://spark.dracodev.net/"
* },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
*
* URL des applications (contrat dev/prod, v0.1.5 puis v0.1.6) —
* résolution, dans l'ordre STRICT, pour chaque app :
* 1. DRACO_BAR_CONFIG.urls.<app>, injectée par l'app hôte (rendu
* serveur ou client) depuis SA configuration d'environnement —
* explicite, donc prioritaire, même sur un hôte -dev ;
* 2. (v0.1.6) SI l'hôte courant est un hôte -dev dracodev.net
* (première étiquette se terminant par -dev : tiktak-dev,
* kanbante-dev, og-dev, flowdeck-dev, agent-dev…), l'URL sœur
* dérivée avec -dev (voir DEV_URLS). C'est le correctif du bug
* constaté sur tiktak-dev / kanbante-dev, où les liens suivaient
* les noms SANS -dev faute de configuration explicite ;
* 3. le href déjà rendu dans le fragment HTML, c.-à-d. le jeton
* {{URL_<APP>}} remplacé côté serveur par l'app hôte ;
* 4. le défaut officiel ci-dessous (production, noms SANS suffixe).
* Un jeton {{URL_…}} non remplacé n'est jamais une URL : il est ignoré
* partout (config injectée comme href rendu) et ne peut pas être exposé.
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
* La clé spark ne correspond à aucun onglet de la barre : elle est
* résolue et exposée via DRACO_BAR.urls pour le panneau Spark
* (panneau-agent), qui suit le même contrat d'injection. La clé agent,
* elle, a un onglet depuis le chantier 5 (ordre : ObsiGate, Flowdeck,
* TikTak, Kanbante, Console Spark) : elle suit donc l'étape « href
* rendu » comme les autres onglets, en plus d'alimenter le menu de
* la marque et DRACO_BAR.urls.
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
* Action INTERNE (jamais relayée) : 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).
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
* (ouvrir la palette, le panneau Spark, changer de domaine…).
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
* ouvre le menu des applications, construit depuis les onglets, plus
* (depuis le correctif #70) une entrée « Console Spark » vers la
* console de l'agent commun (URL agent résolue, voir ci-dessous) ;
* c'est le moyen de changer d'app en mobile, où les onglets sont
* repliés (v0.1.2). Menu compte : voir ci-dessous.
* L'action « menu » n'est PLUS émise nulle part : le bouton trois points
* a été supprimé (#70) ; les branches onAction("menu") des hôtes
* (TikTak/Kanbante/Flowdeck : retour false pour le menu par défaut ;
* ObsiGate : clic #header-menu-btn) sont donc mortes et ont été
* retirées des gabarits hôtes — le menu natif d'ObsiGate
* (#header-menu-btn) lui-même n'est pas touché.
*
* Retrait du bouton trois points (correctif fusion #70) — le bouton et
* son menu par défaut (console agent + applications) sont supprimés
* du fragment, du JS et du CSS. La console agent reste joignable par
* l'entrée « Console Spark » du menu de la marque (ci-dessous) et
* par le bouton Spark (panneau), donc aucun accès n'est perdu.
* Menu des applications (v0.1.3, étendu #70) — construit depuis les
* onglets présents dans la barre, puis une entrée « Console Spark »
* (logo ✦, rôle « Agent commun ») APRÈS Kanbante, vers l'URL agent
* résolue : 1) config explicite DRACO_BAR_CONFIG.urls.agent,
* 2) dérivation -dev agent-dev sur hôte -dev, 3) href rendu de
* l'onglet « Console Spark » (chantier 5, data-app="agent"),
* 4) défaut officiel prod https://agent.dracodev.net/.
* AUCUN hôte spark-dev n'est inventé :
* la clé spark, elle, suit toujours son propre contrat (explicite >
* href rendu > défaut prod) et ne sert qu'au panneau Spark.
* Focus (correctif #70) — ouverture au clavier : Entrée sur la marque
* (clic natif du lien) ; Espace sur la marque (keydown dédié, car un
* lien ne s'active pas à l'Espace) : ouvre le menu et met le focus
* sur son premier item. Fermeture : Échap (focus rendu au déclencheur
* qui avait ouvert le menu : la marque pour le menu apps, l'avatar
* pour le menu compte), nouvel appui sur le déclencheur (le focus y
* est déjà), choix d'un item, ou clic extérieur — ce dernier cas ne
* déplace VOLONTAIREMENT pas le focus (l'utilisateur vient de cliquer
* ailleurs : recherche, tâche… ; lui voler le focus casserait son
* geste). aria-expanded est tenu à jour sur chaque déclencheur.
* Écouteurs globaux (correctif #70, bug tiktak-dev) — les écouteurs
* clic/Échap de la barre sont posés via EventTarget.prototype (voir
* addGlobalListener), PAS via document.addEventListener. Cause
* prouvée sur TikTak (app/static/js/app.js, navigation #89) : app.js
* remplace document/window.addEventListener par un registre
* (__tcDocWin) et chaque navigation douce appelle tcInit() puis
* __tcTeardown(), qui SUPPRIME tous les écouteurs du registre — y
* compris ceux de draco-barre.js (script defer, enregistré après le
* tcInit() initial). La barre survit à la navigation (hors .layout
* remplacé) mais ses gestionnaires sont morts : un menu ouvert avant
* la navigation reste ouvert (Échap et clic extérieur sans effet) et
* la marque, lien href="/", est interceptée par l'intercepteur SPA
* brut de TikTak (__tcDocAdd, hors registre, donc survivant) comme
* une navigation interne au lieu de basculer le menu. Les écouteurs
* posés sur le prototype ne figurent pas dans le registre et ne sont
* donc jamais supprimés par le teardown ; en complément, le gabarit
* TikTak exclut désormais .draco-bar de l'intercepteur SPA, pour que
* la marque ne déclenche plus de navigation douce. Les autres hôtes
* (SSR, rechargement complet) ne sont pas concernés par le teardown
* mais bénéficient du même code ; repli : si EventTarget.prototype
* est indisponible (mini-DOM de tests), document.addEventListener
* classique est utilisé.
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
* un « B » codé en dur dans le fragment, est un vrai <button>
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
* la première lettre du title {{UTILISATEUR}} remplacé ; sans nom connu,
* le neutre ✦ est conservé (jamais de lettre codée en dur). Le clic
* ouvre un petit menu affichant le nom de l'utilisateur ; les items
* Profil / Déconnexion sont relayés à l'hôte via onAction ou, à défaut,
* via l'événement draco:bar (détail { act: "profil" | "deconnexion" }),
* mais le menu affiche l'identité même sans gestionnaire hôte.
* Accessibilité : vrais <button>, aria-haspopup / aria-expanded,
* role="menu" / "menuitem", fermeture par Échap et clic extérieur.
*/
(function () {
"use strict";
var cfg = window.DRACO_BAR_CONFIG || {};
/* Défauts OFFICIELS (production) — les seuls noms publics présents
* dans ce fichier, documentés comme défauts (contrat dev/prod). */
var DEFAULT_URLS = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
agent: "https://agent.dracodev.net/",
spark: "https://spark.dracodev.net/"
};
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
* est lui-même un hôte -dev dracodev.net (voir isDevHost). spark est
* volontairement absent : aucun hôte spark-dev connu, aucune URL
* inventée ; spark retombe sur href rendu puis défaut prod. */
var DEV_URLS = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante"
};
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
* se terminant par -dev ET domaine dracodev.net (un suffixe -dev sur
* un autre domaine, ou un hôte piège du type x-dev.dracodev.net.evil,
* ne déclenche rien). Insensible à la casse. */
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
/* Un jeton {{…}} non remplacé n'est pas une URL : l'ignorer. */
function cleanUrl(u) {
if (!u || typeof u !== "string") return null;
if (u.indexOf("{{") !== -1) return null;
if (!u.replace(/\s/g, "")) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
}
function resolveUrls() {
var host = "";
try { host = (window.location && window.location.hostname) || ""; } catch (e) { host = ""; }
var dev = isDevHost(host);
var urls = {};
Object.keys(DEFAULT_URLS).forEach(function (app) {
/* 1. Config explicite de l'hôte — prioritaire, même sur -dev. */
var u = cleanUrl(cfg.urls && cfg.urls[app]);
/* 2. Dérivation -dev par l'hôte courant (spark : pas de dérivée). */
if (!u && dev) u = DEV_URLS[app] || null;
/* 3. href rendu dans le fragment (jeton remplacé côté serveur). */
if (!u) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var href = tab ? tab.getAttribute("href") : null;
u = cleanUrl(href);
}
/* 4. Défaut officiel (production). */
if (!u) u = DEFAULT_URLS[app];
urls[app] = withSlash(u);
});
return urls;
}
function applyUrls(urls) {
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
if (t.dataset.app && urls[t.dataset.app]) t.setAttribute("href", urls[t.dataset.app]);
});
}
var resolvedUrls = resolveUrls();
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
@@ -43,26 +238,110 @@
});
}
/* ── 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). */
/* ── 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 menu = null;
var avatarBtn = document.querySelector(".draco-avatar");
var appsMenu = null, compteMenu = null;
/* Déclencheur du menu ouvert en dernier (marque ou avatar) : Échap
* lui rend le focus à la fermeture (correctif #70). */
var lastOpener = null;
function buildMenu() {
if (!bar || !brand || menu) return;
menu = document.createElement("div");
menu.className = "draco-apps-menu";
menu.hidden = true;
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Applications");
function setExpanded(btn, open) {
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
}
function refreshMenuHrefs(menu) {
if (!menu) return;
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
});
}
function togglePopup(menu, btn, force) {
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
setExpanded(btn, open);
}
function closeAllPopups(returnFocus) {
var anyOpen = (appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden);
if (appsMenu) togglePopup(appsMenu, brand, false);
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
/* Échap seulement : le clic extérieur ne déplace pas le focus
* (voir l'en-tête, « Focus »). */
if (returnFocus && anyOpen && lastOpener && typeof lastOpener.focus === "function") lastOpener.focus();
}
function tabLabel(app) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var fort = tab ? tab.querySelector(".draco-apptab-txt strong") : null;
return fort ? fort.textContent : (APP_LABELS[app] || app);
}
function tabRole(app) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
return petit ? petit.textContent : "";
}
/* Menu des applications (v0.1.3) — construit depuis les onglets
* présents dans la barre : mêmes liens, mêmes icônes, mêmes badges.
* Ouvert par la marque ✦ (data-act="apps"), fermé par un clic
* extérieur, Échap, ou le choix d'une application (la navigation
* suit son cours). */
function buildAppsMenu() {
if (!bar || !brand || appsMenu) return;
appsMenu = document.createElement("div");
appsMenu.className = "draco-apps-menu";
appsMenu.hidden = true;
appsMenu.setAttribute("role", "menu");
appsMenu.setAttribute("aria-label", "Applications");
document.querySelectorAll(".draco-apptab").forEach(function (t) {
/* Chantier 5 : l'onglet agent (« Console Spark ») n'est PAS
* repris ici — l'entrée dédiée ci-dessous le représente déjà
* (après Kanbante, avec séparateur) ; sans ce saut, le menu
* listerait « Console Spark » deux fois. */
if (t.dataset.app === "agent") return;
var a = document.createElement("a");
a.className = "draco-apps-item";
a.href = t.getAttribute("href");
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");
@@ -84,44 +363,194 @@
a.appendChild(txt);
var badge = t.querySelector(".draco-badge");
if (badge) a.appendChild(badge.cloneNode(true));
menu.appendChild(a);
appsMenu.appendChild(a);
});
bar.appendChild(menu);
/* Entrée « Console Spark » (#70) : après Kanbante, vers la console
* de l'agent commun (URL agent résolue — explicite > -dev >
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
* autres items ; son icône CLONE celle de l'onglet agent
* (2026-10-29, logos barre-apps, corrigé le même jour) : le logo
* fourni, tel quel, jamais redessiné — le CSS --agent (rose) des
* autres éléments de la barre n'est pas changé ici. */
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
appsMenu.appendChild(sep);
var sparkItem = document.createElement("a");
sparkItem.className = "draco-apps-item";
sparkItem.href = resolvedUrls.agent || "#";
sparkItem.dataset.app = "agent";
sparkItem.setAttribute("role", "menuitem");
var sparkIco;
var agentTab = document.querySelector('.draco-apptab[data-app="agent"]');
var agentIco = agentTab ? agentTab.querySelector(".draco-apptab-ico") : null;
if (agentIco) {
sparkIco = agentIco.cloneNode(true);
} else {
sparkIco = document.createElement("span");
sparkIco.className = "draco-apptab-ico";
sparkIco.setAttribute("style", "--app:#d21f2c");
sparkIco.setAttribute("data-icon", "agent");
sparkIco.textContent = "✦";
}
sparkItem.appendChild(sparkIco);
var sparkTxt = document.createElement("span");
sparkTxt.className = "draco-apps-item-txt";
var sparkNom = document.createElement("span");
sparkNom.className = "draco-apps-item-nom";
sparkNom.textContent = "Console Spark";
sparkTxt.appendChild(sparkNom);
var sparkRole = document.createElement("span");
sparkRole.className = "draco-apps-item-role";
sparkRole.textContent = "Agent commun";
sparkTxt.appendChild(sparkRole);
sparkItem.appendChild(sparkTxt);
appsMenu.appendChild(sparkItem);
bar.appendChild(appsMenu);
}
function toggleAppsMenu(force) {
buildAppsMenu();
if (!appsMenu) return;
if (force !== false) refreshMenuHrefs(appsMenu);
togglePopup(appsMenu, brand, force);
if (!appsMenu.hidden) lastOpener = brand;
}
function toggleMenu(force) {
buildMenu();
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
* remplit l'avatar depuis /api/auth/me après chargement). */
function buildCompteMenu() {
if (!bar || !avatarBtn || compteMenu) return;
compteMenu = document.createElement("div");
compteMenu.className = "draco-compte-menu";
compteMenu.hidden = true;
compteMenu.setAttribute("role", "menu");
compteMenu.setAttribute("aria-label", "Compte");
var head = document.createElement("div");
head.className = "draco-compte-nom";
compteMenu.appendChild(head);
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", pair[0]);
b.setAttribute("role", "menuitem");
b.textContent = pair[1];
compteMenu.appendChild(b);
});
bar.appendChild(compteMenu);
}
function refreshCompteMenu() {
if (!compteMenu) return;
var head = compteMenu.querySelector(".draco-compte-nom");
var name = displayName();
if (head) head.textContent = name || "Compte";
}
function toggleCompteMenu(force) {
buildCompteMenu();
if (!compteMenu) return;
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
togglePopup(compteMenu, avatarBtn, force);
if (!compteMenu.hidden) lastOpener = avatarBtn;
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (el && el.dataset.act === "apps") {
ev.preventDefault();
toggleMenu();
/* Relais d'une action vers l'hôte : onAction si présent, sinon
* événement draco:bar (contrat historique des actions relayées). */
function relay(act, el, ev) {
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
}
/* Écouteurs globaux posés sur le prototype EventTarget quand il
* existe, pour survivre au registre/teardown d'un hôte SPA (TikTak,
* voir l'en-tête) ; sinon document.addEventListener classique. */
function addGlobalListener(type, fn) {
try {
if (typeof EventTarget !== "undefined" && EventTarget.prototype &&
EventTarget.prototype.addEventListener) {
EventTarget.prototype.addEventListener.call(document, type, fn, false);
return;
}
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
toggleMenu(false);
} catch (e) { /* repli ci-dessous */ }
document.addEventListener(type, fn);
}
addGlobalListener("click", function (ev) {
/* Items du menu compte : relais + fermeture, jamais de navigation. */
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
if (compteItem) {
relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
toggleCompteMenu(false);
return;
}
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
if (el && el.dataset.act === "apps") {
ev.preventDefault();
if (compteMenu) toggleCompteMenu(false);
toggleAppsMenu();
return;
}
if (el && el.dataset.act === "compte") {
if (appsMenu) toggleAppsMenu(false);
toggleCompteMenu();
return;
}
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
* sur son déclencheur déjà traité ci-dessus). */
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
ev.target.closest(".draco-compte-menu")) : null;
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
closeAllPopups(false);
}
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
relay(act, el, ev);
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") toggleMenu(false);
addGlobalListener("keydown", function (ev) {
if (ev.key === "Escape") { closeAllPopups(true); return; }
/* Espace sur la marque : un lien ne s'active pas à l'Espace, donc
* ouverture explicite + focus sur le premier item du menu (#70).
* Entrée : clic natif du lien, déjà géré par le gestionnaire clic
* ci-dessus (pas de double bascule ici). */
if ((ev.key === " " || ev.key === "Spacebar") && ev.target && ev.target.closest) {
var brandEl = ev.target.closest('[data-act="apps"]');
if (brandEl) {
if (ev.preventDefault) ev.preventDefault();
toggleAppsMenu();
if (appsMenu && !appsMenu.hidden) {
var first = appsMenu.querySelector(".draco-apps-item");
if (first && typeof first.focus === "function") first.focus();
}
}
}
});
/* API publique : l'app met les compteurs à jour sans recharger. */
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
/* 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);
}
};
buildMenu();
applyUrls(resolvedUrls);
buildAppsMenu();
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
<!-- TikTak — deux arcs (les deux temps), coche-flèche. Bleu #0076fb, orange #fc750c.
Géométrie mesurée sur le visuel validé par Bruno (variante B). -->
<path d="M 38.2 101.1 A 84 84 0 0 1 201.8 101.1" fill="none" stroke="#0076fb" stroke-width="28" stroke-linecap="round"/>
<path d="M 201.5 140.3 A 84 84 0 0 1 38.5 140.3" fill="none" stroke="#fc750c" stroke-width="28" stroke-linecap="round"/>
<polyline points="80,129 106,146 163,84" fill="none" stroke="#0076fb" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 128 80 L 163 84 L 167 122" fill="none" stroke="#0076fb" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 760 B

+42 -85
View File
@@ -47,35 +47,9 @@
href="/static/icons/icon-192x192.svg"
/>
<!-- Boot splash CSS — inline so it applies BEFORE /static/style.css is
available (desktop: embedded page served from tauri:// has no /static) -->
<style id="boot-splash-css">
body:not(.app-booted) > *:not(#boot-splash) { display: none !important; }
#boot-splash {
position: fixed; inset: 0; z-index: 2147483647;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(1200px 600px at 50% -10%, #26272b 0%, #17181a 60%);
color: #eceeeb; font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
}
body.app-booted #boot-splash { display: none; }
.bs-card { display: flex; flex-direction: column; align-items: center; gap: 14px; }
.bs-logo {
width: 72px; height: 72px; display: flex; align-items: center; justify-content: center;
font-size: 38px; background: #1f2023; border: 1px solid #33353a; border-radius: 18px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
}
.bs-title { font-size: 22px; font-weight: 600; letter-spacing: 0.3px; }
.bs-spinner {
width: 34px; height: 34px; border-radius: 50%;
border: 3px solid rgba(124, 58, 237, 0.25); border-top-color: #7c3aed;
animation: bs-spin 0.9s linear infinite;
}
@keyframes bs-spin { to { transform: rotate(360deg); } }
.bs-status { font-size: 13px; color: #9aa3b2; min-height: 18px; }
@media (prefers-reduced-motion: reduce) { .bs-spinner { animation-duration: 1.8s; } }
</style>
<link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/draco-barre.css" />
<link rel="stylesheet" href="/static/draco-alertes.css" />
<link rel="stylesheet" href="/static/draco-panneau.css" />
<link
rel="stylesheet"
@@ -88,7 +62,7 @@
id="hljs-theme-light"
disabled
/>
<!-- defer: ne bloque pas le premier paint (le splash inline s'affiche immédiatement) -->
<!-- defer: ne bloque pas le premier paint -->
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<!-- Langues highlight.js hors bundle "common" (enregistrement auto apres le core) -->
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/dockerfile.min.js"></script>
@@ -225,44 +199,6 @@
</script>
</head>
<body class="has-draco-bar">
<!-- Boot splash : visible tant que l'app n'a pas booté avec son CSS.
Sur desktop, la page embarquée (tauri://) n'a pas /static/style.css →
sans ce splash, le squelette HTML brut serait visible pendant le
démarrage du backend (FOUC 3-10 s). Styles 100% inline. -->
<div id="boot-splash" role="status" aria-live="polite">
<div class="bs-card">
<div class="bs-logo">📖</div>
<div class="bs-title">ObsiGate</div>
<div class="bs-spinner" aria-hidden="true"></div>
<div class="bs-status" id="boot-splash-status">Démarrage…</div>
</div>
</div>
<script>
(function () {
var done = false;
function bootReady() {
if (done) return;
done = true;
document.body.classList.add("app-booted");
var s = document.getElementById("boot-splash");
if (s && s.parentNode) s.parentNode.removeChild(s);
}
window.__obsigateBootReady = bootReady;
window.__setBootStatus = function (txt) {
var el = document.getElementById("boot-splash-status");
if (el) el.textContent = txt;
};
var poll = setInterval(function () {
if (window.__OBSIGATE_BOOTED) { clearInterval(poll); bootReady(); }
}, 80);
// Filet de sécurité : si le CSS principal a chargé (origine backend réelle)
// mais que le JS a échoué, révéler l'app après 10 s au lieu d'un splash infini.
setTimeout(function () {
var link = document.querySelector('link[href*="style.css"]');
if (link && link.sheet) { clearInterval(poll); bootReady(); }
}, 10000);
})();
</script>
<div class="search-progress-bar" id="search-progress-bar">
<div class="search-progress-bar__fill"></div>
</div>
@@ -332,35 +268,59 @@
<!-- 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.4
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.11
(ui/barre-apps/) : ne pas modifier la structure ici ; toute
évolution passe par une nouvelle version de commons.
CSS/JS : /static/draco-barre.css, /static/draco-barre.js. -->
CSS/JS : /static/draco-barre.css, /static/draco-barre.js.
Icônes logos v0.1.9 (2026-10-29) : les 5 tuiles sont les logos
fournis, tels quels, servis depuis /static/img/logos/ (copie de
ui/barre-apps/logos/ de dracodev-commons) ; l'entrée « Console Spark »
du menu clone l'icône de l'onglet agent (draco-barre.js).
v0.1.10 (2026-10-29) : --agent rose → rouge Spark #c00000, marque ✦
→ logo Spark 26 px, bouton Spark rose supprimé (remplacé par
l'avatar flottant bas-centre /static/img/avatar-spark.png,
data-act="spark" → panneau Spark), Alertes = cloche seule.
v0.1.11 (2026-10-29) : marque → étoile Spark monochrome
/static/img/brand-spark.png 26 px (blanche sur barre foncée
via [data-theme="dark"] d'ObsiGate, noire sur barre claire
via le filtre brightness(0) du CSS vendangé — aucune
surcharge propre nécessaire, la barre suit déjà
data-theme) ; onglet agent GARDANT le logo rouge ;
anneau rouge #c00000 (bordure 3 px) autour de l'avatar
flottant, via le CSS vendangé.
Chantier 5 (fusion #69/#71, sans bump) : 5e onglet Console Spark
(data-app="agent", défaut prod ; la réécriture DRACO_URLS
couvre la clé "agent" comme les autres onglets), après Kanbante ;
l'entrée « Console Spark » du menu de la marque est conservée. -->
<header class="draco-bar" data-current="obsigate">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" 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>
<span class="draco-brand-mark"><img src="/static/img/brand-spark.png" alt="" width="26" height="26"></span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-ico" style="--app:#7a5af5" data-icon="obsigate"><img src="/static/img/logos/obsigate.png" alt="ObsiGate" width="30" height="30"></span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a>
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-ico" style="--app:#2f6fed" data-icon="flowdeck"><img src="/static/img/logos/flowdeck.png" alt="Flowdeck" width="30" height="30"></span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a>
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-ico" style="--app:#e8762c" data-icon="tiktak"><img src="/static/img/logos/tiktak.svg" alt="TikTak" width="30" height="30"></span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a>
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
<span class="draco-apptab-ico" style="--app:#0d9488" data-icon="kanbante"><img src="/static/img/logos/kanbante.png" alt="Kanbante" width="30" height="30"></span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
<a class="draco-apptab" data-app="agent" href="https://agent.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#c00000" data-icon="agent"><img src="/static/img/logos/spark.png" alt="Console Spark" width="30" height="30"></span>
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
</a>
</nav>
</div>
<div class="draco-bar-zone draco-bar-centre">
@@ -379,24 +339,20 @@
</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">•••</button>
<span class="draco-avatar" id="draco-avatar" title="">✦</span>
<button type="button" class="draco-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</button>
</div>
</header>
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
<button type="button" class="draco-spark-flottant" data-act="spark" aria-label="Ouvrir Spark, l'agent commun"><img src="/static/img/avatar-spark.png" alt="" width="64" height="64"></button>
<script>
/* Configuration ObsiGate de la barre commune (Fusion #215).
Phase 1 : navigation entre apps et domaine mémorisé
localement ; la recherche focus la recherche native
d'ObsiGate ; Alertes unifiées et Spark arrivent avec la
fédération / l'agent commun (phases suivantes). */
d'ObsiGate ; Alertes unifiées : panneau draco-alertes.js
(Fusion #69, agrégation à la demande, pas une fédération
temps réel) ; Spark via le panneau commun. */
/* #223 : les href des onglets portent les défauts officiels ;
l'hôte les réécrit depuis window.DRACO_URLS (injecté par le
backend depuis sa configuration) quand il est présent. */
@@ -431,9 +387,9 @@
if (b) b.click();
return;
}
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
if (act === "palette") { location.href = "/recherche"; return; }
/* Correctif fusion #70 : relais "menu" supprimé avec le bouton trois points de la barre (la console agent est dans le menu de la marque, entrée « Console Spark »). Le bouton natif #header-menu-btn d'ObsiGate lui-même est inchangé et reste utilisable depuis l'en-tête ObsiGate. */
if (act === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { dire("Alertes unifiées : le module n'a pas pu se charger."); } return; }
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
@@ -6065,6 +6021,7 @@ curl -X POST https://votre-serveur.com/webhook \
</div>
</div>
</div>
<script src="/static/draco-alertes.js"></script>
<script defer src="/static/draco-barre.js"></script>
<script defer src="/static/draco-liens.js"></script>
<script defer src="/static/draco-panneau.js"></script>
-8
View File
@@ -120,16 +120,8 @@ async function init() {
}
document.addEventListener("DOMContentLoaded", async () => {
try {
await init();
Sync.init();
} finally {
// Signale au splash de démarrage (index.html #boot-splash) que l'app est
// initialisée : le splash est retiré et le squelette stylé révélé.
// Set AFTER init() resolves so every handler (search, tabs, …) is bound.
window.__OBSIGATE_BOOTED = true;
if (typeof window.__obsigateBootReady === "function") window.__obsigateBootReady();
}
// Show version in header — always visible, fallback before the async fetch
populateVersions();
fetch('/api/health')
+60 -59
View File
@@ -4,9 +4,15 @@
*
* Règles de sécurité appliquées ici (jamais assouplies) :
* - Le backend ne renvoie que des métadonnées (has_value, jamais la valeur,
* jamais un texte chiffré, jamais un nonce). Ce module n'affiche donc
* jamais de valeur de secret après enregistrement : colonne valeur =
* « •••••• » seulement, aucun bouton « afficher ».
* jamais un texte chiffré, jamais un nonce). La valeur d'un secret
* enregistré reste donc masquée (« •••••• ») par défaut ; seul le
* gestionnaire du coffre (propriétaire, ou admin sur coffre
* d'instance) peut la révéler, via POST .../secrets/{nom}/reveler
* (session + gestion, chaque révélation auditée) : bouton
* Afficher/Masquer dans le détail, ou « Copier la valeur » sans
* l'afficher. La valeur est effacée dès qu'elle est masquée ou que
* le détail change ; un simple lecteur par octroi utilisateur ne
* révèle jamais.
* - Aucune valeur ne transite par un titre d'onglet, une URL, un hash, un
* journal console ou un log. Les variables contenant une valeur sont
* effacées dès que l'opération est terminée (champ vidé, variable = null).
@@ -14,10 +20,11 @@
* consommateur (csk_…) et une KEK générée sont rendus UNE fois, dans une
* modale, avec un bouton Copier et un avertissement ; la modale fermée,
* ils sont perdus. Ce ne sont pas des valeurs de secret du coffre.
* - La copie manuelle à usage unique (admin seulement, voir
* _copieManuelle) passe par un consommateur temporaire créé puis révoqué
* immédiatement, avec distribution auditée ; il n'existe pas encore
* d'endpoint de distribution par session utilisateur côté backend.
* - L'ancienne copie manuelle à usage unique par consommateur
* temporaire a été supprimée : elle échouait systématiquement
* (aucun octroi pour le consommateur temporaire, donc 403
* octroi_absent à la distribution). Elle est remplacée par
* « Copier la valeur », qui passe par l'endpoint de révélation.
* - Phase 2 (issue #20) : « Importer l'existant » migre les clés de
* data/api_keys.json vers le coffre d'instance « ObsiGate interne »,
* en deux temps (import, puis purge confirmée). Aucune valeur ne
@@ -52,6 +59,8 @@ let _coffresCache = null;
let _coffresCacheAt = 0;
const CACHE_MS = 30000;
let _valeurAffichee = null; // valeur révélée du détail courant, sinon null
const viewState = {
coffres: [],
currentSlug: null,
@@ -354,6 +363,7 @@ function renderAlerts(host) {
function renderDetail(host) {
const s = viewState.detail;
_valeurAffichee = null;
host.innerHTML = '';
if (!s) {
host.appendChild(text('p', t('coffres.select_secret'), 'coffres-empty'));
@@ -377,7 +387,9 @@ function renderDetail(host) {
const dl = el('dl', { class: 'coffres-dl' }, []);
rows.forEach(([k, v]) => {
dl.appendChild(el('dt', {}, [document.createTextNode(k)]));
dl.appendChild(el('dd', {}, [document.createTextNode(v)]));
const dd = el('dd', {}, [document.createTextNode(v)]);
if (k === t('coffres.value')) dd.id = 'coffres-valeur';
dl.appendChild(dd);
});
host.appendChild(dl);
const actions = el('div', { class: 'coffres-actions' }, []);
@@ -386,11 +398,36 @@ function renderDetail(host) {
actions.appendChild(modalBtn(t('coffres.acces'), '', () => {
document.getElementById('coffres-acces')?.scrollIntoView({ behavior: 'smooth' });
}));
actions.appendChild(modalBtn(t('coffres.manual_copy'), '', () => copieManuelle(s)));
if (!isAdmin()) {
actions.lastChild.disabled = true;
actions.lastChild.title = t('coffres.manual_copy_admin_only');
const btnAfficher = modalBtn(t('coffres.show_value'), '', async () => {
if (_valeurAffichee !== null) {
_valeurAffichee = null;
document.getElementById('coffres-valeur').textContent = '••••••';
btnAfficher.textContent = t('coffres.show_value');
return;
}
let valeur;
try {
valeur = await revelerValeur(s);
} catch (e) {
showToast(e.message || t('common.error'), 'error');
return;
}
_valeurAffichee = valeur;
document.getElementById('coffres-valeur').textContent = valeur;
btnAfficher.textContent = t('coffres.hide_value');
});
actions.appendChild(btnAfficher);
actions.appendChild(modalBtn(t('coffres.copy_value'), '', async () => {
let valeur;
try {
valeur = await revelerValeur(s);
} catch (e) {
showToast(e.message || t('common.error'), 'error');
return;
}
await copyText(valeur);
valeur = null;
}));
actions.appendChild(modalBtn(t('coffres.delete'), 'danger', () => supprimerSecret(s)));
host.appendChild(actions);
@@ -819,55 +856,19 @@ function openCreateCoffreModal() {
]);
}
/** Copie manuelle à usage unique, admin seulement.
* Le backend n'expose pas (encore) de distribution par session : on passe
* par un consommateur temporaire créé, utilisé une fois, puis révoqué
* immédiatement. La valeur est copiée au presse-papiers sans jamais être
* affichée, puis la variable est effacée, même en cas d'échec. */
async function copieManuelle(s) {
if (!isAdmin()) return;
const ok = await showConfirm({
title: t('coffres.manual_copy'),
message: t('coffres.manual_copy_confirm'),
danger: true,
});
if (!ok) return;
let consommateurId = null;
let valeur = null;
try {
const created = await api('/api/coffres/consommateurs', {
method: 'POST',
body: JSON.stringify({ nom: `copie-manuelle-${me()}-${Date.now()}` }),
});
consommateurId = created.id;
const jeton = created.jeton;
const res = await fetch('/api/coffres/distribuer', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${jeton}`,
},
body: JSON.stringify({
reference: s.reference,
contexte_type: 'copie_manuelle',
contexte_id: `copie-manuelle-${Date.now()}`,
}),
});
/** Révélation manuelle (gestionnaire du coffre seulement, auditée
* côté backend). Ne conserve rien : l'appelant affiche ou copie
* puis efface. Un lecteur par octroi utilisateur reçoit 403. */
async function revelerValeur(s) {
const ref = (s.reference || '').replace(/^coffre:/, '');
const [slug, nom] = ref.split('/');
const res = await fetch(
`/api/coffres/${encodeURIComponent(slug)}/secrets/${encodeURIComponent(nom)}/reveler`,
{ method: 'POST', credentials: 'include' },
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || `Erreur ${res.status}`);
valeur = data.valeur;
await copyText(valeur);
} catch (e) {
showToast(e.message || t('common.error'), 'error');
} finally {
valeur = null;
if (consommateurId) {
try {
await api(`/api/coffres/consommateurs/${encodeURIComponent(consommateurId)}`, { method: 'DELETE' });
} catch { /* révocation best-effort, l'octroi reste révocable depuis Accès */ }
}
}
return data.valeur;
}
/** Phase 2 — « Importer l'existant » (admin seulement, deux temps).
File diff suppressed because it is too large Load Diff
+29 -18
View File
@@ -209,6 +209,17 @@ function _openSecretsMenu(x, y) {
}, 0);
}
/**
* Vault déclaré en lecture seule (DIR_N_READONLY, champ « readonly » de
* /api/vaults) : ses menus et boutons d'action ne doivent proposer aucune
* mutation (renommer, déplacer, supprimer…) — le backend les refuse en
* 403 de toute façon, l'UI le dit avant le clic.
*/
function vaultIsReadonly(vaultName) {
const v = (state.allVaults || []).find((x) => x.name === vaultName);
return !!(v && v.readonly);
}
/** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon();
@@ -356,11 +367,11 @@ async function refreshSidebarForContext() {
vaultItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
const isReadonly = false;
const isReadonly = vaultIsReadonly(v.name);
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
});
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
attachTreeItemActionButton(vaultItem, v.name, "", "vault", vaultIsReadonly(v.name));
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: vaultIsReadonly(v.name) }));
container.appendChild(vaultItem);
@@ -460,11 +471,11 @@ async function loadVaults() {
vaultItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
const isReadonly = false;
const isReadonly = vaultIsReadonly(v.name);
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
});
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
attachTreeItemActionButton(vaultItem, v.name, "", "vault", vaultIsReadonly(v.name));
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: vaultIsReadonly(v.name) }));
container.appendChild(vaultItem);
@@ -596,8 +607,8 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
// New item — create it
if (item.type === "directory") {
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "directory" }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", vaultIsReadonly(vaultName));
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: vaultIsReadonly(vaultName) }));
fragment.appendChild(dirItem);
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
@@ -624,14 +635,14 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
dirItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false);
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", vaultIsReadonly(vaultName));
});
} else {
const fileIconName = getFileIcon(item.name);
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [sharedFileIconSync(sharedPaths, item.name, vaultName, item.path), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", vaultIsReadonly(vaultName));
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: vaultIsReadonly(vaultName) }));
fileItem.addEventListener("click", () => {
scrollTreeItemIntoView(fileItem, false);
if (window.innerWidth <= 768) {
@@ -649,7 +660,7 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
fileItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false);
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", vaultIsReadonly(vaultName));
});
fragment.appendChild(fileItem);
@@ -944,8 +955,8 @@ async function loadDirectory(vaultName, dirPath, container) {
if (item.type === "directory") {
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "directory" }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", vaultIsReadonly(vaultName));
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: vaultIsReadonly(vaultName) }));
fragment.appendChild(dirItem);
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
@@ -972,7 +983,7 @@ async function loadDirectory(vaultName, dirPath, container) {
dirItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
const isReadonly = false;
const isReadonly = vaultIsReadonly(vaultName);
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly);
});
} else {
@@ -980,8 +991,8 @@ async function loadDirectory(vaultName, dirPath, container) {
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
const isShared = sharedPaths.has(`${vaultName}::${item.path}`);
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [isShared ? _sharedIcon() : icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", vaultIsReadonly(vaultName));
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: vaultIsReadonly(vaultName) }));
fileItem.addEventListener("click", () => {
scrollTreeItemIntoView(fileItem, false);
if (window.innerWidth <= 768) {
@@ -999,7 +1010,7 @@ async function loadDirectory(vaultName, dirPath, container) {
fileItem.addEventListener("contextmenu", (e) => {
e.preventDefault();
const isReadonly = false;
const isReadonly = vaultIsReadonly(vaultName);
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly);
});
+3
View File
@@ -2680,6 +2680,9 @@
"coffres.copy_failed": "Copy failed.",
"coffres.copy_generated": "Copy the generated value",
"coffres.copy_reference": "Copy",
"coffres.show_value": "Show",
"coffres.hide_value": "Hide",
"coffres.copy_value": "Copy value",
"coffres.create_coffre_title": "New vault",
"coffres.create_octroi": "Create a grant",
"coffres.create_secret_title": "New secret",
+3
View File
@@ -2680,6 +2680,9 @@
"coffres.copy_failed": "Copie impossible.",
"coffres.copy_generated": "Copier la valeur générée",
"coffres.copy_reference": "Copier",
"coffres.show_value": "Afficher",
"coffres.hide_value": "Masquer",
"coffres.copy_value": "Copier la valeur",
"coffres.create_coffre_title": "Nouveau coffre",
"coffres.create_octroi": "Créer un octroi",
"coffres.create_secret_title": "Nouveau secret",
+111
View File
@@ -0,0 +1,111 @@
/* 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 var(--border, #888); border-radius: 8px; background: inherit; color: inherit;
}
.rp-input:focus { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
.rp-section { margin-bottom: 28px; }
.rp-head {
display: flex; align-items: baseline; gap: 10px;
border-bottom: 1px solid var(--border, #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: var(--accent, #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 var(--accent, #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; }
/* ---- Onglets (chantier 2+3) : mêmes onglets sur les 4 hôtes. ---- */
.rp-tabs {
display: flex; gap: 4px; flex-wrap: wrap;
border-bottom: 1px solid var(--border, #888); margin-bottom: 20px;
}
.rp-tab {
background: none; border: none; border-bottom: 2px solid transparent;
padding: 8px 10px; margin-bottom: -1px; font: inherit; cursor: pointer;
color: inherit; opacity: .7; white-space: nowrap;
}
.rp-tab:hover { opacity: 1; }
.rp-tab.active { opacity: 1; border-bottom-color: var(--accent, #3b82f6); font-weight: 700; }
.rp-tab:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
.rp-tab-count {
font-size: .8rem; font-weight: 700; background: rgba(127, 127, 127, .25);
border-radius: 999px; padding: 1px 7px; margin-left: 2px;
}
.rp-tab-count:empty { display: none; }
.rp-section[hidden] { display: none; }
.rp-all[hidden] { display: none; }
/* ---- TikTak : la page sort de la colonne sidebar (chantier 2+3).
* Cause : recherche.html étend base.html sans layout_class, donc
* .rp-page (enfant direct de .layout) tombait en auto-placement dans
* la 2e colonne de la grille (largeur sidebar). La règle ci-dessous,
* gardée par l'ancêtre .layout (TikTak seul), l'étend de la colonne 2
* à la fin de la grille — juste à droite du rail d'icônes — sur toute
* la largeur disponible, sans toucher aux autres pages (elles ne
* portent pas .rp-page). En mobile (≤760 px, point de bascule TikTak),
* la grille n'a qu'une colonne : la page la prend entièrement. */
.layout > .rp-page {
grid-column: 2 / -1; max-width: none; min-width: 0; overflow-y: auto;
}
@media (max-width: 760px) {
.layout > .rp-page { grid-column: 1 / -1; }
}
/* ---- ObsiGate : navigation statique de la page autonome (chantier
* 2+3). La page /recherche est servie hors SPA (route avant le
* catch-all) : elle reprend les classes de l'app (style.css :
* .app-container, .header, .main-body, .sidebar, .content-wrapper,
* .content-area) et cette navigation, calée sur les variables de
* l'app, en guise de sidebar. Les vaults dynamiques restent dans
* la SPA (voir le commentaire dans recherche.html). */
.rp-og-nav { display: flex; flex-direction: column; gap: 4px; padding: 12px; }
.rp-og-nav a {
display: block; padding: 8px 10px; border-radius: 8px;
text-decoration: none; color: inherit;
}
.rp-og-nav a:hover { background: rgba(127, 127, 127, .15); }
.rp-og-nav a[aria-current="page"] {
background: rgba(127, 127, 127, .18); font-weight: 700;
}
.rp-og-note { opacity: .65; font-size: .85rem; padding: 8px 10px; margin: 8px 0 0; }
/* ---- Thème (défaut 4) : la page suit le réglage ObsiGate. ----
* L'étape 1 (script synchrone en tête de recherche.html) pose
* data-theme = mode persisté ; l'étape 2 (themes.js) pose la palette
* complète en variables inline sur :root. Les règles .rp-* ci-dessus
* utilisent donc var(--border) / var(--accent) avec les anciennes
* valeurs en repli, et le reste hérite déjà du corps (style.css).
* color-scheme : les contrôles natifs (barres de défilement, champ
* de recherche) suivent le mode au lieu de celui de l'OS.
* Barre : draco-barre.css (fichier commun vendangé, NON modifié ici)
* ne connaît que [data-theme="dark"] ; sans les règles ci-dessous,
* sa media query prefers-color-scheme:dark imposerait une barre
* sombre sur une page claire (OS sombre + mode ObsiGate clair), et
* les modes high-contrast/sepia n'auraient aucune déclinaison.
* Les valeurs reprennent les défauts clairs de draco-barre.css et
* les générateurs _genHighContrast/_genSepia de themes.js. */
[data-theme="dark"], [data-theme="high-contrast"] { color-scheme: dark; }
[data-theme="light"], [data-theme="sepia"] { color-scheme: light; }
[data-theme="light"] .draco-bar { --bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3; --ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9; }
[data-theme="high-contrast"] .draco-bar { --bg: #000000; --surface: #0a0a0a; --surface-2: #141414; --surface-3: #000000; --ink: #ffffff; --ink-2: #cccccc; --ink-3: #888888; --line: #444444; }
[data-theme="sepia"] .draco-bar { --bg: #f5ecd5; --surface: #eee3c8; --surface-2: #e8dcc0; --surface-3: #f5ecd5; --ink: #3d2e1a; --ink-2: #6b5a3e; --ink-3: #9a8a6a; --line: #c8b890; }
+199
View File
@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rechercher partout — ObsiGate</title>
<!-- Gabarit ObsiGate (chantier 2+3) : la page /recherche est servie
hors SPA (route déclarée avant le catch-all, comme /admin.html),
elle reprend donc les feuilles de l'app (style.css + barre) et
la structure de index.html (barre de fusion, en-tête, sidebar,
zone de contenu) au lieu de la page nue précédente.
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.
La barre ci-dessous est une copie statique du snippet figé de
index.html (commons v0.1.11 + chantier 5 fusion #69/#71, sans
bump) : les onglets sont des liens réels, le bouton central
replace le curseur dans la boîte (recherche.js,
clic data-act="palette"). Re-vendoring alignement recherche :
5e onglet Console Spark (data-app="agent", jeton {{URL_AGENT}}
remplacé par la route /recherche comme les 4 autres) et
icône SVG du bouton Alertes, repris d'index.html. Les menus,
le panneau Spark, les alertes et le domaine restent des
comportements de la SPA et ne sont pas rejoués ici.
Sidebar statique (navigation de l'app) : les vaults, tags et
récents dynamiques vivent dans la SPA, pas sur cette page. -->
<script>
/* Thème ObsiGate, étape 1 (synchrone, avant les feuilles) : poser
data-theme = mode persisté AVANT la première peinture, sinon la
page flasherait en sombre puis basculerait. Sources lues :
'obsigate-theme-mode' (SPA modulaire, js/ui.js initTheme) puis,
à défaut, 'obsigate-theme' quand il vaut déjà un mode (valeur
historique de l'app monolithique) ; toute autre valeur — clé de
thème comme 'dracula', mode inconnu, stockage indisponible —
retombe sur 'dark', le défaut SPA. La palette complète (clé)
est appliquée à l'étape 2 en fin de page. */
(function () {
var MODES = ['dark', 'light', 'high-contrast', 'sepia'];
var mode = null;
try { mode = window.localStorage.getItem('obsigate-theme-mode'); } catch (e) { mode = null; }
if (MODES.indexOf(mode) === -1) {
try {
var legacy = window.localStorage.getItem('obsigate-theme');
if (MODES.indexOf(legacy) !== -1) { mode = legacy; }
} catch (e) { /* stockage indisponible : repli sombre */ }
}
if (MODES.indexOf(mode) === -1) { mode = 'dark'; }
document.documentElement.setAttribute('data-theme', mode);
})();
</script>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/draco-barre.css">
<link rel="stylesheet" href="/static/recherche.css">
</head>
<body class="has-draco-bar rp-og">
<header class="draco-bar" data-current="obsigate">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
<span class="draco-brand-mark"><img src="/static/img/brand-spark.png" alt="" width="26" height="26"></span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="{{URL_OBSIGATE}}">
<span class="draco-apptab-ico" style="--app:#7a5af5" data-icon="obsigate"><img src="/static/img/logos/obsigate.png" alt="ObsiGate" width="30" height="30"></span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a>
<a class="draco-apptab" data-app="flowdeck" href="{{URL_FLOWDECK}}">
<span class="draco-apptab-ico" style="--app:#2f6fed" data-icon="flowdeck"><img src="/static/img/logos/flowdeck.png" alt="Flowdeck" width="30" height="30"></span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a>
<a class="draco-apptab" data-app="tiktak" href="{{URL_TIKTAK}}">
<span class="draco-apptab-ico" style="--app:#e8762c" data-icon="tiktak"><img src="/static/img/logos/tiktak.svg" alt="TikTak" width="30" height="30"></span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a>
<a class="draco-apptab" data-app="kanbante" href="{{URL_KANBANTE}}">
<span class="draco-apptab-ico" style="--app:#0d9488" data-icon="kanbante"><img src="/static/img/logos/kanbante.png" alt="Kanbante" width="30" height="30"></span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
<a class="draco-apptab" data-app="agent" href="{{URL_AGENT}}">
<span class="draco-apptab-ico" style="--app:#c00000" data-icon="agent"><img src="/static/img/logos/spark.png" alt="Console Spark" width="30" height="30"></span>
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
</a>
</nav>
</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-badge" data-badge="alertes" hidden>0</span>
</button>
<button type="button" class="draco-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</button>
</div>
</header>
<button type="button" class="draco-spark-flottant" data-act="spark" aria-label="Ouvrir Spark, l'agent commun"><img src="/static/img/avatar-spark.png" alt="" width="64" height="64"></button>
<div id="app" class="app-container">
<header class="header">
<div class="header-left">
<div class="header-logo">ObsiGate</div>
<a href="/">Retour à l'application</a>
</div>
</header>
<div class="main-body">
<aside class="sidebar" id="sidebar" role="navigation" aria-label="Navigation ObsiGate">
<nav class="rp-og-nav">
<a href="/">Tableau de bord</a>
<a href="/recherche" aria-current="page">Rechercher partout</a>
<p class="rp-og-note">La navigation par vaults (fichiers, tags, récents) vit dans l'application ObsiGate.</p>
</nav>
</aside>
<div class="content-wrapper">
<div class="content-area">
<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. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value=""
placeholder="Rechercher partout…" autocomplete="off" autofocus>
</div>
<nav class="rp-tabs" role="tablist" aria-label="Résultats par application">
<button type="button" class="rp-tab active" role="tab" aria-selected="true" data-rp-tab="all">Tout <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="obsigate">ObsiGate <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="flowdeck">Flowdeck <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="tiktak">TikTak <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="kanbante">Kanbante <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="console">Console Spark <span class="rp-tab-count"></span></button>
</nav>
<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>
<section class="rp-section" data-rp-section="console">
<div class="rp-head"><h2>Console Spark — discussions</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
</main>
</div>
</div>
</div>
</div>
<script src="/static/js/recherche.js"></script>
<script type="module">
/* Thème ObsiGate, étape 2 (différée) : appliquer la palette
complète via le moteur de thèmes de la SPA (themes.js), seule
source de vérité des 16 thèmes, des modes high-contrast/sepia
et des thèmes personnalisés — aucune table recopiée ici.
initThemes() relit localStorage et pose les variables inline
sur :root + data-theme = mode, comme au démarrage de la SPA
(js/sync.js init). Garde historique : si 'obsigate-theme'
vaut encore un mode ('dark'/'light', app monolithique) sans
'obsigate-theme-mode', on garde le mode de l'étape 1 —
initThemes() y verrait une clé inconnue et retomberait sur
'spark'/'dark'. Échec de chargement silencieux : la page
garde alors le mode de l'étape 1, recherche.js n'est pas
touché (fichier partagé avec les 3 autres hôtes). */
(function () {
var saved = null;
var savedMode = null;
try { saved = window.localStorage.getItem('obsigate-theme'); } catch (e) {}
try { savedMode = window.localStorage.getItem('obsigate-theme-mode'); } catch (e) {}
if (savedMode === null && (saved === 'dark' || saved === 'light')) { return; }
import('/static/js/themes.js').then(function (mod) {
if (mod && typeof mod.initThemes === 'function') { mod.initThemes(); }
}).catch(function () {});
})();
</script>
</body>
</html>
+1 -1
View File
@@ -8493,7 +8493,7 @@ body.popup-mode .content-area {
box-shadow: 0 2px 16px rgba(0,0,0,0.35);
width: fit-content;
max-width: 95%;
z-index: 50;
z-index: 66; /* au-dessus de .draco-spark-flottant (65) sinon la barre flottante DRACO intercepte les clics des flèches de recherche */
backdrop-filter: blur(8px);
}
.results-bar-count {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.81.0",
"version": "2.83.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+1 -1
View File
@@ -25,7 +25,7 @@ async function login(page) {
await page.click('#login-btn');
}
// Wait until app init() resolved (all handlers bound).
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
}
async function openFile(page, vault, filePath) {
+1 -1
View File
@@ -28,7 +28,7 @@ async function login(page) {
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
}
async function authEnabled(page) {
+1 -1
View File
@@ -26,7 +26,7 @@ async function login(page) {
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
}
async function openFile(page, vault, filePath) {
+1 -1
View File
@@ -25,7 +25,7 @@ async function login(page) {
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
}
async function openFile(page, vault, filePath) {
+1 -1
View File
@@ -12,7 +12,7 @@ import { test, expect } from '@playwright/test';
async function boot(page) {
await page.goto('/');
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
await page.waitForSelector('#dashboard-home, #search-input', { timeout: 15000 });
}
+1 -1
View File
@@ -9,7 +9,7 @@ const BASE = process.env.BASE_URL || 'http://localhost:2029';
async function goHome(page) {
await page.goto(BASE);
// Wait until app init() resolved (all handlers bound, incl. search).
await page.waitForFunction(() => (window as any).__OBSIGATE_BOOTED === true, { timeout: 20000 });
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
await page.waitForSelector('#dashboard-home, #search-input', { timeout: 15000 });
}
+1 -1
View File
@@ -30,7 +30,7 @@ async function login(page) {
await page.fill('#login-password', CREDS.password);
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
}
async function openFile(page, vault, filePath) {
+1 -1
View File
@@ -57,7 +57,7 @@ async function login(page) {
await page.fill('#login-password', process.env.OBSIGATE_PASS || 'test123');
await page.click('#login-btn');
}
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
}
async function openFixture(page) {
+1 -1
View File
@@ -117,7 +117,7 @@ test.describe('XSS — lecteur markdown (BUG-021)', () => {
await api(request, 'POST', `/api/file/${VAULT}`, { path: XSS_FILE, content: `${XSS_BODY}\n` });
await page.goto(BASE);
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
await openFile(page, VAULT, XSS_FILE);
const content = page.locator('#content-area');
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env node
/**
* ObsiGate — Alertes unifiées (Fusion #69, chantier 2 + chantier 5).
*
* Tests du module partagé frontend/draco-alertes.js (logique pure,
* sans DOM) + vérification du gabarit livré frontend/index.html.
* Copie vendangée byte-identique de Projets/dracodev-commons
* ui/alertes/ (chantier 5) — seul le chargement change (ESM ici).
* Le payload console n'est qu'un EXEMPLE DE FORMAT (voir README
* commons) : aucun endpoint console n'est publié sur agent-commun
* main au moment du chantier.
*
* Usage: node tests/frontend/draco-alertes.test.mjs
*/
import test from "node:test";
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..", "..");
const require = createRequire(import.meta.url);
const t = require(path.join(REPO_ROOT, "frontend", "draco-alertes.js"));
function sampleResults() {
return {
flowdeck: { items: t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
notifications: [
{ id: 1, ntype: "mention", title: "Mention budget", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
{ id: 2, ntype: "reminder", title: "Rappel lu", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
], unread: 1,
}) },
tiktak: { items: t.normalizeTiktak("https://tiktak.dracodev.net/", [
{ id: 7, kind: "reminder", title: "Rappel tâche", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
{ id: 8, kind: "share", title: "Partage lu", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
]) },
kanbante: { items: t.normalizeKanbante("https://kanbante.dracodev.net/", {
events: [
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
],
}) },
obsigate: { items: [] },
console: { items: [], sourceMissing: true },
};
}
function allOf(results) {
const all = [];
for (const app of ["obsigate", "flowdeck", "tiktak", "kanbante", "console"]) {
(results[app] && results[app].items || []).forEach((a) => all.push(a));
}
return all;
}
test("ordre des onglets : Tout, apps, Console Spark, Historique", () => {
assert.deepEqual(t.TABS, ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"]);
assert.equal(t.TAB_LABELS.console, "Console Spark");
});
test("resolveUrls: config explicite > hote -dev > href > defaut prod, agent inclus", () => {
const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} });
assert.equal(prod.tiktak, "https://tiktak.dracodev.net/");
assert.equal(prod.agent, "https://agent.dracodev.net/");
const dev = t.resolveUrls({ hostname: "tiktak-dev.dracodev.net", tabHrefs: {} });
assert.equal(dev.flowdeck, "https://flowdeck-dev.dracodev.net/");
assert.equal(dev.obsigate, "https://og-dev.dracodev.net/");
assert.equal(dev.agent, "https://agent-dev.dracodev.net/");
const explicit = t.resolveUrls({
hostname: "tiktak-dev.dracodev.net",
configUrls: { flowdeck: "https://flowdeck.dracodev.net/" },
});
assert.equal(explicit.flowdeck, "https://flowdeck.dracodev.net/");
const href = t.resolveUrls({
hostname: "example.org",
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/", agent: "https://agent.dracodev.net/" },
});
assert.equal(href.kanbante, "https://kanbante-dev.dracodev.net/");
assert.equal(href.agent, "https://agent.dracodev.net/");
const token = t.resolveUrls({ hostname: "x", tabHrefs: { tiktak: "{{URL_TIKTAK}}" } });
assert.equal(token.tiktak, "https://tiktak.dracodev.net/");
assert.equal(t.isDevHost("tiktak-dev.dracodev.net.evil.com"), false);
assert.equal(t.isDevHost("x-dev.dracodev.net"), true);
});
test("normalizeFlowdeck: non lues actives, lien = base + url", () => {
const items = t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
notifications: [
{ id: 1, ntype: "mention", title: "Mention", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
{ id: 2, ntype: "reminder", title: "Rappel", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
], unread: 1,
});
assert.equal(items.length, 2);
assert.equal(items[0].active, true);
assert.equal(items[0].state, "active");
assert.equal(items[0].url, "https://flowdeck.dracodev.net/collections/3");
assert.equal(items[1].active, false);
assert.equal(items[1].url, "https://flowdeck.dracodev.net/");
});
test("normalizeTiktak: lien tache, sinon page notifications", () => {
const items = t.normalizeTiktak("https://tiktak.dracodev.net/", [
{ id: 7, kind: "reminder", title: "Rappel", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
{ id: 8, kind: "share", title: "Partage", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
]);
assert.equal(items[0].url, "https://tiktak.dracodev.net/?task=42");
assert.equal(items[1].url, "https://tiktak.dracodev.net/notifications");
assert.equal(items[0].active, true);
});
test("normalizeKanbante: jamais actif (pas d'etat lu/non lu), lien board", () => {
const items = t.normalizeKanbante("https://kanbante.dracodev.net/", {
events: [
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
],
});
assert.equal(items.length, 2);
assert.ok(items.every((i) => i.active === false));
assert.equal(items[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps");
assert.match(items[0].title, /#69/);
});
test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
const list = [
{ app: "tiktak", active: false, date: "2026-10-28T12:00:00" },
{ app: "flowdeck", active: true, date: "2026-10-26T12:00:00" },
{ app: "flowdeck", active: true, date: "2026-10-28T12:00:00" },
{ app: "kanbante", active: false, date: "" },
];
const sorted = t.sortAlerts(list);
assert.equal(sorted[0].date, "2026-10-28T12:00:00");
assert.equal(sorted[0].active, true);
assert.equal(sorted[1].date, "2026-10-26T12:00:00");
assert.equal(sorted[3].date, "");
assert.equal(t.countActive(list), 2);
});
test("scopeAlerts: Tout et onglets apps = actives seulement ; Historique = fermees seulement", () => {
const r = sampleResults();
const all = allOf(r);
const tout = t.scopeAlerts(all, "tout", r);
assert.equal(tout.items.length, 2);
assert.ok(tout.items.every((a) => a.active));
assert.equal(tout.items[0].date, "2026-10-28T10:00:00"); // date decroissante
const fd = t.scopeAlerts(all, "flowdeck", r);
assert.equal(fd.items.length, 1);
assert.equal(fd.items[0].app, "flowdeck");
const hist = t.scopeAlerts(all, "historique", r);
assert.equal(hist.items.length, 4); // 1 FD lue + 1 TK lue + 2 KB
assert.ok(hist.items.every((a) => !a.active));
const og = t.scopeAlerts(all, "obsigate", r);
assert.equal(og.items.length, 0);
const cs = t.scopeAlerts(all, "console", r);
assert.equal(cs.items.length, 0);
assert.equal(cs.consoleMissing, true);
});
test("scopeAlerts: une section en echec remonte aussi dans Historique (pas avalee)", () => {
const r = sampleResults();
r.flowdeck = { items: [], error: "Application injoignable depuis cet hôte (TypeError)." };
const hist = t.scopeAlerts(allOf(r), "historique", r);
assert.equal(hist.errors.length, 1);
assert.equal(hist.errors[0].app, "flowdeck");
const tout = t.scopeAlerts(allOf(r), "tout", r);
assert.equal(tout.errors.length, 1);
});
test("countByTab: compteurs par onglet sur l'echantillon", () => {
const r = sampleResults();
const c = t.countByTab(allOf(r), r);
assert.deepEqual(c, {
tout: 2, obsigate: 0, flowdeck: 1, tiktak: 1,
kanbante: 0, console: 0, historique: 4,
});
});
test("filterAlerts: titre + contexte, insensible a la casse, sans melanger actifs/historique", () => {
const r = sampleResults();
const all = allOf(r);
const tout = t.scopeAlerts(all, "tout", r).items;
assert.equal(t.filterAlerts(tout, "budget").length, 1);
assert.equal(t.filterAlerts(tout, "BUDGET").length, 1);
assert.equal(t.filterAlerts(tout, "voir page").length, 1); // contexte (detail)
assert.equal(t.filterAlerts(tout, "tache").length, 1);
// « Rappel lu » est une alerte fermee : absente de Tout meme filtree.
assert.equal(t.filterAlerts(tout, "Rappel lu").length, 0);
const hist = t.scopeAlerts(all, "historique", r).items;
assert.equal(t.filterAlerts(hist, "Rappel lu").length, 1);
assert.equal(t.filterAlerts(tout, "").length, tout.length);
});
test("presentation identique: meta Kanbante sans type brut anglais ; date illisible = sans date", () => {
const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", {
events: [{ id: 1, repo_full_name: "R", issue_number: 1, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" }],
})[0];
assert.ok(!t.metaLine(kb).includes("moved"));
assert.match(t.metaLine(kb), /Kanbante · .* · Déplacée/);
assert.equal(t.displayState({ active: true }), "Active");
assert.equal(t.displayState({ active: false, app: "flowdeck" }), "Lue");
const sansDate = { app: "tiktak", active: true, title: "x", detail: "", date: "nimporte-quoi", kind: "", url: "https://x/" };
assert.match(t.metaLine(sansDate), /sans date/);
});
test("hash URL: parse + round-trip, onglet inconnu rejete", () => {
assert.equal(t.parseAlertTab("#alertes=historique"), "historique");
assert.equal(t.parseAlertTab("#alertes=console"), "console");
assert.equal(t.parseAlertTab("#alertes"), "tout");
assert.equal(t.parseAlertTab("#autre"), null);
assert.equal(t.parseAlertTab("#alertes=nimporte"), null);
assert.equal(t.parseAlertTab(""), null);
for (const tab of t.TABS) assert.equal(t.parseAlertTab(t.hashForTab(tab)), tab);
assert.equal(t.hashForTab("nimporte"), "#alertes=tout");
});
test("normalizeConsole (FORMAT EXEMPLE): statuts reels -> actif/ferme, lien console par defaut", () => {
const items = t.normalizeConsole("https://agent.dracodev.net/", {
alerts: [
{ id: "m1", title: "Mission en pause budget", detail: "budget épuisé", status: "pause_budget", created_at: "2026-10-28T09:00:00" },
{ id: "m2", title: "Accès requis", detail: "jeton manquant", status: "acces_requis", created_at: "2026-10-28T09:30:00" },
{ id: "a1", title: "Approbation en attente", status: "en_attente", url: "/approbations", created_at: "2026-10-28T10:00:00" },
{ id: "m3", title: "Mission terminée", status: "terminee", created_at: "2026-10-27T10:00:00" },
{ id: "m4", title: "Sans statut", created_at: "2026-10-27T11:00:00" },
],
});
assert.equal(items.length, 5);
assert.equal(items[0].active, true);
assert.equal(items[1].active, true);
assert.equal(items[2].active, true);
assert.equal(items[2].url, "https://agent.dracodev.net/approbations");
assert.equal(items[3].active, false);
assert.equal(items[4].active, true); // statut inconnu : expose en active, jamais masque
assert.equal(items[0].url, "https://agent.dracodev.net/");
const r = sampleResults();
r.console = { items };
const c = t.countByTab(allOf(r), r);
assert.equal(c.console, 4);
assert.equal(c.tout, 6);
assert.equal(c.historique, 5);
});
test("consoleSourceUrl: null sans configuration (aucune URL devineee)", () => {
assert.equal(t.consoleSourceUrl({}), null);
assert.equal(t.consoleSourceUrl({ consoleUrl: "https://agent-dev.dracodev.net/api/alertes" }),
"https://agent-dev.dracodev.net/api/alertes");
});
test("index.html: placeholder Alertes disparu, module charge", () => {
const html = readFileSync(path.join(REPO_ROOT, "frontend", "index.html"), "utf8");
assert.equal(html.includes("Alertes unifiées : arrive avec la fédération"), false);
assert.equal(html.includes("/static/draco-alertes.js"), true);
assert.equal(html.includes("/static/draco-alertes.css"), true);
assert.equal(html.includes("DRACO_ALERTES.open()"), true);
});
test("index.html chantier 5: onglet Console Spark en dernier, apres Kanbante", () => {
const html = readFileSync(path.join(REPO_ROOT, "frontend", "index.html"), "utf8");
const nav = /<nav class="draco-apptabs"[^>]*>([\s\S]*?)<\/nav>/.exec(html)[1];
const apps = [...nav.matchAll(/data-app="([^"]+)"/g)].map((m) => m[1]);
assert.deepEqual(apps, ["obsigate", "flowdeck", "tiktak", "kanbante", "agent"]);
assert.ok(nav.includes('data-app="agent" href="https://agent.dracodev.net/"'));
assert.ok(nav.includes("<strong>Console Spark</strong>"));
// La réécriture DRACO_URLS couvre la clé "agent" (même boucle que les autres onglets).
assert.ok(html.includes('window.DRACO_URLS[a.dataset.app]'));
});
test("draco-alertes.js/.css chantier 5: onglets, filtre, etat console honnete", () => {
const js = readFileSync(path.join(REPO_ROOT, "frontend", "draco-alertes.js"), "utf8");
assert.ok(js.includes("draco-alertes-tab"));
assert.ok(js.includes("historique"));
assert.ok(js.includes("source indisponible"));
assert.ok(js.includes("DRACO_ALERTES_CONSOLE_URL"));
const css = readFileSync(path.join(REPO_ROOT, "frontend", "draco-alertes.css"), "utf8");
assert.ok(css.includes(".draco-alertes-tab"));
assert.ok(css.includes(".draco-alertes-filtre"));
});
+179
View File
@@ -0,0 +1,179 @@
#!/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",
);
/* ---- Chantier 2+3 : onglets, compteurs, URL, console (logique pure) ---- */
/* parseTab */
ok(rp.parseTab("obsigate") === "obsigate", "onglet valide conservé");
ok(rp.parseTab("console") === "console", "onglet console valide");
ok(rp.parseTab("bidon") === "all", "onglet inconnu -> Tout");
ok(rp.parseTab(undefined) === "all", "onglet absent -> Tout");
/* visibleKeys : Tout = les 4 sections groupées, sans la console */
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "Tout = 4 sections, pas la console");
ok(JSON.stringify(rp.visibleKeys("tiktak")) === JSON.stringify(["tiktak"]), "onglet app = sa seule section");
ok(JSON.stringify(rp.visibleKeys("console")) === JSON.stringify(["console"]), "onglet console = sa seule section");
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "onglet inconnu -> Tout");
/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */
let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i }));
ok(rp.pageItems(douze, false).length === 8, "vue Tout tronquée à 8");
ok(rp.pageItems(douze, true).length === 12, "onglet app sans troncature");
/* tabCounts : "…" en chargement, total sinon, "" en erreur (pas de faux 0) */
let c = rp.tabCounts({
obsigate: { status: "loading", total: 0 },
flowdeck: { status: "ok", total: 3 },
tiktak: { status: "empty", total: 0 },
kanbante: { status: "error", total: 0 },
console: { status: "idle", total: 0 },
});
ok(c.obsigate === "…", "compteur … pendant le chargement");
ok(c.flowdeck === "3" && c.tiktak === "0", "compteur total, vide honnête à 0");
ok(c.kanbante === "" && c.console === "", "erreur/inactif : pas de faux 0");
ok(c.all === "…", "Tout = … tant qu'une section charge");
c = rp.tabCounts({
obsigate: { status: "ok", total: 5 },
flowdeck: { status: "ok", total: 2 },
tiktak: { status: "empty", total: 0 },
kanbante: { status: "error", total: 0 },
console: { status: "ok", total: 4 },
});
ok(c.all === "7", "Tout = somme des totaux connus");
ok(c.console === "4", "compteur console");
c = rp.tabCounts({
obsigate: { status: "error", total: 0 },
flowdeck: { status: "error", total: 0 },
tiktak: { status: "error", total: 0 },
kanbante: { status: "error", total: 0 },
console: { status: "idle", total: 0 },
});
ok(c.all === "", "Tout vide si les 4 sections sont en erreur");
/* buildSearchUrl : état onglet partageable */
ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet");
ok(rp.buildSearchUrl("x", "all") === "?q=x", "URL Tout sans param tab");
ok(rp.buildSearchUrl("", "all") === "", "URL vide sans recherche ni onglet");
ok(rp.buildSearchUrl("", "console") === "?tab=console", "URL onglet seul");
/* searchPath complet (onglet) vs tronqué (Tout) */
ok(rp.searchPath("obsigate", "café", true) === "api/search?q=caf%C3%A9&vault=all&limit=100", "path ObsiGate complet (100)");
ok(rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8", "path ObsiGate Tout (8)");
/* normalize complet vs tronqué */
let taches12 = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: "T" + i, status: "active" }));
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t", true).length === 12, "normalize complet");
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t").length === 8, "normalize tronqué par défaut");
/* normalizeConsole : formes tolérées, pas de faux lien, format inconnu signalé */
let nc = rp.normalizeConsole({ results: [{ title: "D", url: "https://agent.dracodev.net/console/1", snippet: "s" }] }, "https://agent.dracodev.net/");
ok(nc.recognized === true && nc.items[0].url === "https://agent.dracodev.net/console/1", "console : URL absolue conservée");
nc = rp.normalizeConsole({ discussions: [{ name: "Conv", href: "/discussions/9" }] }, "https://agent-dev.dracodev.net/");
ok(nc.recognized === true && nc.items[0].url === "https://agent-dev.dracodev.net/discussions/9", "console : URL relative préfixée");
nc = rp.normalizeConsole({ items: [{ title: "Sans lien", id: 7 }] }, "https://agent.dracodev.net/");
ok(nc.recognized === true && nc.items[0].url === null, "console : sans URL -> pas de faux lien");
nc = rp.normalizeConsole({ autre: [] }, "https://agent.dracodev.net/");
ok(nc.recognized === false, "console : format inconnu -> non reconnu (indisponible, pas vide)");
/* resolveConsoleBase : explicite > agent injecté > dev > href barre > prod */
ok(rp.resolveConsoleBase({ explicit: "https://agent-perso.example/", agentUrl: "https://agent-autre.example/" }) === "https://agent-perso.example/", "console : explicite prioritaire");
ok(rp.resolveConsoleBase({ agentUrl: "https://agent-injecte.example/" }) === "https://agent-injecte.example/", "console : agent injecté par l'hôte");
ok(rp.resolveConsoleBase({ host: "tiktak-dev.dracodev.net" }) === "https://agent-dev.dracodev.net/", "console : dérivation -dev");
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { console: "https://console-perso.example/" } }) === "https://console-perso.example/", "console : href onglet barre");
ok(rp.resolveConsoleBase({}) === "https://agent.dracodev.net/", "console : défaut prod");
/* ---- Alignement console (agent-commun /api/v1/recherche) ---- */
let rc;
ok(rp.CONSOLE_SEARCH_PATH === "api/v1/recherche", "console : chemin reel /api/v1/recherche");
ok(rp.CONSOLE_SEARCH_LIMIT === 20, "console : limite 20 (plafond par type)");
rc = rp.normalizeConsole({ resultats: [
{ type: "discussion", id: "c42", titre: "Conv", extrait: "ex", statut: null, domaine: "pro", section: "conversation", objet: "c42", lien: "/?section=conversation&disc=c42" },
{ type: "mission", id: "t1", titre: "Mission", extrait: "ex", statut: "paused_budget", domaine: "pro", section: "missions", objet: "t1", lien: "/?section=missions&objet=t1" },
{ type: "approbation", id: "a9", titre: "publier", extrait: "ex", statut: "pending", domaine: null, section: "approbations", objet: "a9", lien: "/?section=approbations&objet=a9" },
{ type: "objectif", id: "o3", titre: "Obj", extrait: "ex", statut: "actif", domaine: "pro", section: "objectifs", objet: "o3", lien: "/?section=objectifs&objet=o3" },
{ type: "activite", id: "a7", titre: "Act", extrait: "ex", statut: "terminee", domaine: "pro", section: null, objet: "a7", lien: "/?activite=a7" },
{ type: "livrable", id: "l1", titre: "Liv", extrait: "ex", statut: "terminee", domaine: "pro", section: "livrables", objet: null, lien: "/?section=livrables" },
{ type: "planification", id: "p1", titre: "Plan", extrait: "ex", statut: "active", domaine: "pro", section: "planifie", objet: null, lien: "/?section=planifie" },
{ type: "identite", id: "i1", titre: "Id", extrait: "ex", statut: "active", domaine: null, section: "identites", objet: null, lien: "/?section=identites" },
] }, "https://agent-dev.dracodev.net/");
ok(rc.recognized === true && rc.items.length === 8, "console : 8 resultats reconnus");
ok(rc.items[0].url === "https://agent-dev.dracodev.net/?section=conversation&disc=c42", "console : lien profond discussion");
ok(rc.items[1].url === "https://agent-dev.dracodev.net/?section=missions&objet=t1" && rc.items[1].context === "Mission \u00b7 paused_budget \u00b7 pro", "console : lien mission + contexte statut/domaine");
ok(rc.items[2].url === "https://agent-dev.dracodev.net/?section=approbations&objet=a9", "console : lien profond approbation");
ok(rc.items[3].url === "https://agent-dev.dracodev.net/?section=objectifs&objet=o3", "console : lien profond objectif");
ok(rc.items[4].url === "https://agent-dev.dracodev.net/?activite=a7", "console : lien activite sans section");
ok(rc.items[5].url === "https://agent-dev.dracodev.net/?section=livrables", "console : livrable = vue seulement");
ok(rc.items[6].url === "https://agent-dev.dracodev.net/?section=planifie", "console : planification = vue seulement");
ok(rc.items[7].url === "https://agent-dev.dracodev.net/?section=identites", "console : identite = vue seulement");
rc = rp.normalizeConsole({ resultats: [
{ type: "mission", id: "t9", titre: "Sans lien", extrait: "", statut: null, domaine: null, section: "missions", objet: "t9" },
{ type: "activite", id: "a9", titre: "Act sans lien", extrait: "", statut: null, domaine: null, section: null, objet: "a9" },
] }, "https://agent.dracodev.net/");
ok(rc.items[0].url === "https://agent.dracodev.net/?section=missions&objet=t9", "console : lien reconstruit sans lien servi");
ok(rc.items[1].url === "https://agent.dracodev.net/?activite=a9", "console : lien activite reconstruit");
rc = rp.normalizeConsole({ resultats: [] }, "https://agent.dracodev.net/");
ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide reconnu (vide honnete, pas indisponible)");
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
console.log(`recherche.test.mjs : ${n} assertions OK`);
+3 -1
View File
@@ -112,8 +112,10 @@ function testModulesHaveImports() {
{ file: 'navfacets.js', reason: 'pure helpers — no deps, imported by vaulthome.js' },
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
{ file: 'recherche.js', reason: 'standalone — vanilla sans dépendance, vendangé tel quel par chaque hôte (#68)', noExport: true },
];
const expectNoImport = new Set(expected.map(e => e.file));
const expectNoExport = new Set(expected.filter(e => e.noExport).map(e => e.file));
for (const file of files) {
const content = readFileSync(join(JS_DIR, file), 'utf-8');
@@ -123,7 +125,7 @@ function testModulesHaveImports() {
if (!expectNoImport.has(file)) {
assert.ok(hasImport, `${file} should have at least one import`);
}
if (file !== 'state.js' && file !== 'app.js') {
if (file !== 'state.js' && file !== 'app.js' && !expectNoExport.has(file)) {
assert.ok(hasExport, `${file} should have at least one export`);
}
}
+153
View File
@@ -44,6 +44,8 @@ from backend.coffre_crypto import (
)
from backend.coffre_store import (
CoffreExisteError,
create_octroi,
list_secrets,
SecretExpireError,
ValidationError,
create_coffre,
@@ -131,6 +133,17 @@ def _creer_coffre_secret(client, slug="personnel", nom="gitea-token", valeur=VAL
return resp.json()
def _creer_secret(client, slug="personnel", nom="second",
valeur=VALEUR_TEST_2):
resp = client.post(
f"/api/coffres/{slug}/secrets",
json={"nom": nom, "type": "token", "service": "Test",
"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
@@ -678,3 +691,143 @@ class TestConsommateurAvecOctrois:
assert len(resp.json()["octrois"]) == 1
assert len(resp.json()["erreurs"]) == 1
assert resp.json()["erreurs"][0]["coffre_slug"] == "inexistant"
# ---------------------------------------------------------------------------
# Liste des distribuables pour un consommateur (issue #43)
# ---------------------------------------------------------------------------
class TestDistribuables:
def _lister(self, client, jeton=None):
headers = {}
if jeton is not None:
headers["Authorization"] = f"Bearer {jeton}"
return client.get("/api/coffres/distribuables", headers=headers)
def test_ne_liste_que_les_octrois_actifs_metadonnees_seules(
self, client, env_coffre):
_creer_coffre_secret(client) # personnel/gitea-token
_creer_secret(client, nom="sans-octroi")
consommateur, jeton, octroi = _creer_consommateur_octroi(client)
resp = self._lister(client, jeton)
assert resp.status_code == 200, resp.text
assert resp.json() == [{
"reference": "coffre:personnel/gitea-token",
"coffre": "personnel",
"nom": "gitea-token",
"version": 1,
}]
assert VALEUR_TEST not in resp.text
assert VALEUR_TEST_2 not in resp.text
assert "valeur" not in resp.text
# Octroi révoqué : le secret disparaît, sans erreur.
client.delete(f"/api/coffres/octrois/{octroi['id']}")
assert self._lister(client, jeton).json() == []
def test_octroi_expire_exclu_et_coffre_wide_inclus(
self, client, env_coffre):
from datetime import datetime, timedelta, timezone
_creer_coffre_secret(client)
_creer_secret(client, nom="second")
consommateur, jeton, _octroi = _creer_consommateur_octroi(client)
# Octroi expiré (hier) sur « second » : exclu.
coffre = get_coffre_or_404("personnel")
second = [x for x in list_secrets(coffre["id"])
if x["nom"] == "second"][0]
create_octroi(coffre, "consommateur", consommateur["id"],
second["id"], "bruno",
expiration=(datetime.now(timezone.utc)
- timedelta(days=1)).isoformat())
# Octroi coffre-wide actif : couvre tous les secrets.
resp = client.post(
"/api/coffres/personnel/octrois",
json={"cible_type": "consommateur",
"cible_id": consommateur["id"],
"secret_nom": None},
)
# Si l'API refuse le coffre-wide sans nom, on le crée direct.
if resp.status_code != 200:
create_octroi(coffre, "consommateur", consommateur["id"],
None, "bruno")
refs = {x["reference"]
for x in self._lister(client, jeton).json()}
assert "coffre:personnel/gitea-token" in refs
assert "coffre:personnel/second" in refs
def test_auth_consommateur_uniquement(self, client, env_coffre):
_creer_coffre_secret(client)
_creer_consommateur_octroi(client)
# Sans jeton : 401 (la session utilisateur ne suffit pas).
assert self._lister(client).status_code == 401
# Jeton inconnu : 401, sans révéler d'inventaire.
assert self._lister(client, "jeton-inconnu").status_code == 401
# Consommateur sans octroi : liste vide, pas d'erreur.
resp = client.post("/api/coffres/consommateurs",
json={"nom": "autre-app"})
autre_jeton = resp.json()["jeton"]
assert self._lister(client, autre_jeton).json() == []
def test_consommateur_revoque_et_flag_desactive(
self, client, env_coffre, monkeypatch):
_creer_coffre_secret(client)
consommateur, jeton, _octroi = _creer_consommateur_octroi(client)
client.delete(f"/api/coffres/consommateurs/{consommateur['id']}")
resp = self._lister(client, jeton)
assert resp.status_code == 403
assert resp.json()["detail"] == "consommateur_revoque"
# Flag désactivé : 503 comme les autres routes.
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
assert self._lister(client, jeton).status_code == 503
# ---------------------------------------------------------------------------
# Révélation manuelle (panneau détail : Afficher / Copier la valeur)
# ---------------------------------------------------------------------------
class TestRevelation:
def test_proprietaire_revele_et_audit_sans_valeur(self, client, env_coffre):
_creer_coffre_secret(client)
resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler")
assert resp.status_code == 200, resp.text
corps = resp.json()
assert corps["valeur"] == VALEUR_TEST
assert corps["reference"] == "coffre:personnel/gitea-token"
assert corps["version"] == 1
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
assert "revelation_manuelle" in audit
assert VALEUR_TEST not in audit
def test_lecteur_octroi_utilisateur_ne_revele_pas(self, client):
_creer_coffre_secret(client)
resp = client.post("/api/coffres/personnel/octrois", json={
"cible_type": "utilisateur", "cible_id": "alice",
"secret_nom": "gitea-token"})
assert resp.status_code == 200, resp.text
_connecter(client, "alice", role="user")
# Voit les métadonnées…
assert client.get("/api/coffres/personnel/secrets").status_code == 200
# …mais ne révèle jamais la valeur.
resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler")
assert resp.status_code == 403
def test_secret_inexistant_404(self, client):
_creer_coffre_secret(client)
resp = client.post("/api/coffres/personnel/secrets/absent/reveler")
assert resp.status_code == 404
def test_secret_expire_410(self, client):
_creer_coffre_secret(client)
hier = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
resp = client.post("/api/coffres/personnel/secrets", json={
"nom": "vieux-jeton", "type": "token", "valeur": VALEUR_TEST,
"expiration": hier})
assert resp.status_code == 200, resp.text
resp = client.post("/api/coffres/personnel/secrets/vieux-jeton/reveler")
assert resp.status_code == 410
assert resp.json()["detail"] == "secret_expire"
def test_flag_desactive_503(self, client, monkeypatch):
_creer_coffre_secret(client)
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler")
assert resp.status_code == 503
+19 -5
View File
@@ -34,13 +34,25 @@ def test_palier_mobile_resserre_v012():
def test_menu_des_applications_par_la_marque():
# v0.1.3 : la marque ✦ ouvre le menu des 4 applications
# (changement d'app en mobile), logo à 24 px.
# v0.1.3 : la marque ouvre le menu des applications
# (changement d'app en mobile). v0.1.10 : la marque n'est plus
# le glyphe ✦ à 24 px mais le logo Spark en img 26 px.
# v0.1.11 : la marque n'est plus le logo rouge mais l'étoile
# Spark monochrome (brand-spark.png) ; l'onglet agent, lui,
# garde le logo rouge.
html = _index()
assert 'data-act="apps"' in html
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
assert "draco-apps-menu" in js
assert "font-size: 24px" in _css()
assert '<span class="draco-brand-mark"><img src="/static/img/brand-spark.png"' in html
assert ".draco-brand-mark img" in _css()
assert "width: 26px" in _css()
assert "filter: brightness(0)" in _css()
assert '[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }' in _css()
assert 'data-icon="agent"><img src="/static/img/logos/spark.png"' in html
assert "border: 3px solid var(--agent)" in _css()
recherche = (FRONTEND / "recherche.html").read_text(encoding="utf-8")
assert '<span class="draco-brand-mark"><img src="/static/img/brand-spark.png"' in recherche
def test_panneau_spark_integre_218():
@@ -87,7 +99,8 @@ def test_panneau_console_surimpression_222():
assert "draco-console-sur" in panneau
assert "assurerSurimpression" in panneau
assert "draco-console-styles" in panneau
assert 'surCadre.src = urlConsole()' in panneau
assert 'var url = urlConsole()' in panneau
assert 'surCadre.src = url' in panneau
assert 'surEl.style.width = "100%"' in panneau
assert 'data-dc="sidebar"' in panneau
assert "Mode sidebar" in panneau
@@ -104,7 +117,8 @@ def test_csp_ouvre_le_service_d_agents_218():
# dérivée de la configuration (backend.draco_config.agent_origine,
# testée via la config dans tests/test_security_headers.py).
main = (Path(__file__).resolve().parent.parent / "backend" / "main.py").read_text(encoding="utf-8")
ligne = next(l for l in main.splitlines() if "connect-src" in l)
ligne = next(l for l in main.splitlines()
if "connect-src" in l and not l.strip().startswith("#"))
assert "agent_origine" in main
assert "{agent}" in ligne
+100
View File
@@ -0,0 +1,100 @@
"""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():
# Correctif 2026-10-28 : 10 origines officielles (8 sœurs + 2 agent), prod ET dev,
# quelle que soit la résolution.
assert sister_origins(RESOLVED_DEV) == {
"https://og.dracodev.net",
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
"https://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
"https://agent.dracodev.net",
"https://agent-dev.dracodev.net",
}
def test_cors_headers_agent_console_autorise():
# Fédération depuis la console (2026-10-28) : les origines agent
# prod ET dev passent, avec credentials ; un tiers reste refusé.
for origine in ("https://agent.dracodev.net",
"https://agent-dev.dracodev.net"):
headers = cors_headers_for(origine, RESOLVED_DEV)
assert headers is not None
assert headers["Access-Control-Allow-Origin"] == origine
assert headers["Access-Control-Allow-Credentials"] == "true"
assert cors_headers_for("https://agent.dracodev.net.evil.example",
RESOLVED_DEV) is None
def test_sister_origins_ajoute_env_de_la_requete():
# Correctif 2026-10-28 : configuration muette, quel que soit
# l'hôte de la requête, prod ET dev passent — l'hôte ne
# restreint plus l'allowlist.
assert "https://og-dev.dracodev.net" in sister_origins(
{}, request_host="og-dev.dracodev.net"
)
assert "https://og-dev.dracodev.net" in sister_origins(
{}, request_host="og.dracodev.net"
)
assert "https://og.dracodev.net" in sister_origins(
{}, request_host="og-dev.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-dev.dracodev.net.evil.example",
RESOLVED_DEV) is None
# Autre environnement autorisé (correctif 2026-10-28).
assert cors_headers_for("https://og.dracodev.net",
RESOLVED_DEV) is not 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
@@ -0,0 +1,51 @@
"""Tests — page /recherche sans texte parasite dans <head>.
Régression du texte de chantier resté hors commentaire dans
``frontend/recherche.html`` : le commentaire de tête se fermait, puis
un paragraphe de description du mécanisme de thème suivait en texte
brut dans ``<head>`` — le navigateur le rendait tout en haut de la
page, au-dessus de la barre header. Le correctif supprime ce texte
du rendu (il n'est pas remis en commentaire : un grep doit donner
0 occurrence dans les gabarits) sans toucher au mécanisme lui-même,
déjà documenté par les commentaires des scripts étape 1 / étape 2.
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FRONTEND = ROOT / "frontend"
def _html() -> str:
return (FRONTEND / "recherche.html").read_text(encoding="utf-8")
def test_pas_de_texte_brut_dans_head():
"""Entre les balises et les scripts, <head> ne contient aucun
texte rendu : tout descriptif vit dans un commentaire ou dans un
commentaire de script, jamais en texte brut."""
head = _html().split("</head>")[0]
sans_commentaires = re.sub(r"<!--.*?-->", "", head, flags=re.DOTALL)
sans_scripts = re.sub(
r"<script.*?</script>", "", sans_commentaires, flags=re.DOTALL
)
sans_titre = re.sub(
r"<title>.*?</title>", "", sans_scripts, flags=re.DOTALL
)
texte = re.sub(r"<[^>]+>", "", sans_titre).strip()
assert texte == "", f"texte brut rendu dans <head> : {texte[:200]!r}"
def test_mecanisme_theme_intact():
"""Le retrait du texte ne touche ni l'étape 1 (script synchrone
avant les feuilles) ni l'étape 2 (initThemes différé)."""
html = _html()
assert html.index("<script>") < html.index('<link rel="stylesheet"')
assert "obsigate-theme-mode" in html
assert "import('/static/js/themes.js')" in html
assert "initThemes" in html
assert '<html lang="fr" data-theme="dark">' in html
assert '<script src="/static/js/recherche.js"></script>' in html
+167
View File
@@ -0,0 +1,167 @@
"""Tests — thème de la page /recherche ObsiGate (défaut 4).
Mécanisme audité (ne pas réinventer) : le thème ObsiGate n'est PAS une
préférence serveur. La SPA le lit dans localStorage au démarrage —
``frontend/js/ui.js`` ``initTheme`` (mode seul, pour la première
peinture) puis ``frontend/js/sync.js`` ``init`` -> ``themes.js``
``initThemes`` (clé + mode, palette complète en variables inline sur
``:root``). Clés : ``obsigate-theme`` (clé, ex. ``'spark'``,
``'dracula'``, ou historiquement un mode) et ``obsigate-theme-mode``
(``'dark'`` | ``'light'`` | ``'high-contrast'`` | ``'sepia'``).
La page /recherche, servie hors SPA avec un ``data-theme="dark"`` en
dur et aucun script de thème, restait donc sombre quel que soit le
réglage. Correctif verrouillé ici, en deux étapes dans
``frontend/recherche.html`` (aucun fichier partagé touché :
``js/recherche.js`` reste identique sur les 4 hôtes, ``draco-barre.css``
reste le fichier commun vendangé, ``backend/main.py`` et sa CSP
strictes sont inchangés) :
- étape 1 : script synchrone en tête, avant les feuilles — pose
``data-theme`` = mode persisté avant la première peinture ;
- étape 2 : module différé — ``initThemes()`` de ``themes.js`` pose la
palette complète (16 thèmes, modes générés, thèmes personnalisés).
``frontend/recherche.css`` décline la barre pour les modes que
``draco-barre.css`` ne connaît pas et branche bordures/accents sur
``var(--border)`` / ``var(--accent)`` (anciennes valeurs en repli).
"""
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FRONTEND = ROOT / "frontend"
MODES = ["dark", "light", "high-contrast", "sepia"]
NONCE = "TESTNONCE1234567890"
def _html() -> str:
return (FRONTEND / "recherche.html").read_text(encoding="utf-8")
def _css() -> str:
return (FRONTEND / "recherche.css").read_text(encoding="utf-8")
def _inline_scripts(html: str) -> list[str]:
"""Contenu des scripts inline exécutables (sans src)."""
return re.findall(
r"<script(?: type=\"module\")?>(.*?)</script>", html, re.DOTALL
)
def test_etape1_avant_les_feuilles_et_mode_valide():
html = _html()
scripts = _inline_scripts(html)
assert scripts, "aucun script inline dans recherche.html"
etape1 = scripts[0]
assert "obsigate-theme-mode" in etape1
assert "obsigate-theme" in etape1
assert "setAttribute('data-theme'" in etape1 or 'setAttribute("data-theme"' in etape1
for mode in MODES:
assert f"'{mode}'" in etape1, f"mode {mode} absent de l'étape 1"
# Le script étape 1 précède la première feuille de style.
assert html.index("<script>") < html.index('<link rel="stylesheet"')
# Repli sans JS : sombre, le défaut SPA.
assert '<html lang="fr" data-theme="dark">' in html
def test_etape1_ignore_les_cles_de_theme():
"""Une clé ('dracula', 'spark', …) ne doit jamais devenir un mode."""
etape1 = _inline_scripts(_html())[0]
# La validation passe par une liste fermée de modes, jamais par la
# valeur brute de 'obsigate-theme'.
assert "MODES.indexOf(mode)" in etape1
assert "MODES.indexOf(legacy)" in etape1
assert "'dark'" in etape1 # repli final
def test_etape2_importe_themes_js_avec_garde_historique():
html = _html()
modules = re.findall(
r'<script type="module">(.*?)</script>', html, re.DOTALL
)
assert len(modules) == 1, "exactement un module inline attendu"
etape2 = modules[0]
assert "import('/static/js/themes.js')" in etape2
assert "initThemes" in etape2
# Garde historique : 'obsigate-theme' valant encore un mode sans
# 'obsigate-theme-mode' -> on garde le mode de l'étape 1.
assert "savedMode === null" in etape2
assert "saved === 'dark'" in etape2 and "saved === 'light'" in etape2
# Échec de chargement silencieux : la page garde le mode étape 1.
assert ".catch(function () {})" in etape2
# Le module est après recherche.js (différé : il tourne avant
# l'init DOMContentLoaded de recherche.js, sans le modifier).
assert html.index("/static/js/recherche.js") < html.index(
'<script type="module">'
)
# themes.js existe bien à l'URL importée et exporte initThemes.
themes = (FRONTEND / "js" / "themes.js").read_text(encoding="utf-8")
assert "initThemes" in themes
assert "AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia']" in themes
def test_nonce_csp_pour_les_deux_etapes():
"""Comme /recherche injecte window.DRACO_URLS avant </head>, les
trois scripts inline doivent tous recevoir le nonce frais."""
from backend.csp import inject_csp_nonce
html = _html().replace(
"</head>",
'<script>window.DRACO_URLS = {};</script></head>',
1,
)
out = inject_csp_nonce(html, NONCE)
assert not re.findall(r"<script>", out), "script classique sans nonce"
assert not re.findall(
r'<script type="module">', out
), "module inline sans nonce"
assert out.count(f'nonce="{NONCE}"') == 3
def test_barre_decline_tous_les_modes():
"""draco-barre.css ne connaît que [data-theme="dark"] : les autres
modes sont déclinés dans recherche.css (fichier de la page), sans
toucher au fichier commun vendangé."""
css = _css()
barre = (FRONTEND / "draco-barre.css").read_text(encoding="utf-8")
assert '[data-theme="light"] .draco-bar' not in barre
for mode in ("light", "high-contrast", "sepia"):
assert f'[data-theme="{mode}"] .draco-bar' in css, mode
# Valeurs reprises des générateurs themes.js (_genHighContrast /
# _genSepia) : fond noir / sépia, encre blanche / brune.
assert "--surface: #0a0a0a" in css and "--ink: #ffffff" in css
assert "--surface: #eee3c8" in css and "--ink: #3d2e1a" in css
assert "color-scheme: dark" in css and "color-scheme: light" in css
def test_champs_onglets_resultats_suivent_accent_et_bordure():
css = _css()
assert "border: 1px solid var(--border, #888)" in css
assert "background: var(--accent, #3b82f6)" in css
assert "border-bottom-color: var(--accent, #3b82f6)" in css
assert "outline: 2px solid var(--accent, #3b82f6)" in css
def test_invariants_page_intacts():
"""Barre (5e onglet Console Spark), onglets, focus, 'Tout voir',
repli typographique : rien ne régresse."""
html = _html()
for app in ("obsigate", "flowdeck", "tiktak", "kanbante", "agent"):
assert f'data-app="{app}"' in html, app
assert "{{URL_AGENT}}" in html
for tab in ("all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"):
assert f'data-rp-tab="{tab}"' in html, tab
assert "Tout voir" in html
assert 'id="rp-q"' in html and "autofocus" in html
assert '<script src="/static/js/recherche.js"></script>' in html
# Aucune police externe ajoutée : le repli système imposé par la
# CSP stricte de /recherche (font-src 'self' data:) est inchangé.
assert "fonts.googleapis.com" not in html
assert "fonts.gstatic.com" not in html
# Ordre des feuilles inchangé (recherche.css après la barre).
assert html.index("draco-barre.css") < html.index("recherche.css")
+75
View File
@@ -0,0 +1,75 @@
"""Tests — démarrage ObsiGate sans écran de chargement plein écran.
Le splash (``#boot-splash`` dans ``frontend/index.html`` : icône
livre, titre, spinner, « Démarrage… ») masquait tout le reste de la
page via ``body:not(.app-booted) > * { display: none !important }``
jusqu'au signal de ``frontend/js/app.js`` (ou un repli à 10 s).
Les autres applications (TikTak, Kanbante, Flowdeck, console Spark)
n'affichent aucun splash de ce type au démarrage : le contenu y
apparaît dès qu'il est prêt. Le retrait supprime donc l'écran, son
CSS de masquage, son script de pilotage et le signal côté ``app.js``,
sans toucher aux indicateurs internes aux vues ni à l'écran de
connexion.
"""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
FRONTEND = ROOT / "frontend"
MARQUEURS_SPLASH = (
"boot-splash",
"app-booted",
"__OBSIGATE_BOOTED",
"__obsigateBootReady",
"__setBootStatus",
"bs-card",
"bs-logo",
"bs-title",
"bs-spinner",
"bs-status",
"bs-spin",
"Démarrage…",
)
def _index() -> str:
return (FRONTEND / "index.html").read_text(encoding="utf-8")
def _app_js() -> str:
return (FRONTEND / "js" / "app.js").read_text(encoding="utf-8")
def test_aucun_marqueur_splash():
index = _index()
app_js = _app_js()
for marqueur in MARQUEURS_SPLASH:
assert marqueur not in index, f"{marqueur} encore dans index.html"
assert marqueur not in app_js, f"{marqueur} encore dans js/app.js"
def test_pas_de_masquage_global_au_demarrage():
"""Aucune règle ne cache tout le <body> en attendant un signal :
sans elle, un chargement lent ne peut plus donner un écran blanc
bloqué."""
index = _index()
assert "body:not(.app-booted)" not in index
assert "display: none !important" not in index
def test_boot_js_conserve_et_indicateurs_internes_intacts():
"""Le démarrage SPA reste branché ; seuls le splash et son signal
disparaissent. L'écran de connexion et la barre de progression
de recherche (indicateurs internes aux vues) ne sont PAS visés."""
app_js = _app_js()
assert 'document.addEventListener("DOMContentLoaded"' in app_js
assert "await init();" in app_js
assert "Sync.init();" in app_js
assert "populateVersions" in app_js
index = _index()
assert 'id="login-screen"' in index
assert 'id="search-progress-bar"' in index
assert 'id="app"' in index
+1 -1
View File
@@ -33,7 +33,7 @@ class TestListAccessibleVaults:
def test_metadata_fields(self, client):
vault = next(v for v in list_accessible_vaults(ADMIN) if v["name"] == "TestVault")
assert set(vault) == {"name", "file_count", "tag_count", "type"}
assert set(vault) == {"name", "file_count", "tag_count", "type", "readonly"}
assert vault["file_count"] >= 1
+251
View File
@@ -0,0 +1,251 @@
"""Tests des vaults DIR « Spark » : exclusions par vault et lecture seule.
Couvre le contrat DIR_N_READONLY / DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS :
- parsing des trois variables dans load_vault_config() ;
- helper chemin_exclu (noms de dossiers et motifs fnmatch à préfixes) ;
- navigation filtrée, lectures exclues en 404 (vue, raw, téléchargement),
fichier non exclu lisible, indexation et list_paths filtrés ;
- mutation refusée en 403 sur un vault déclaré readonly ;
- /api/vaults expose readonly=true pour un DIR readonly.
Le vault de test est un répertoire temporaire monté en DIR (jamais un
vault de notes), avec une arborescence qui imite la vue Spark visée :
Console/, Workspaces/ (avec un .home), Executeurs/Hermes/{missions,data,.env}.
"""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
from typing import ClassVar
import pytest
from fastapi.testclient import TestClient
VAULT_NAME = "SparkTest"
def _build_spark_tree(root: Path) -> None:
"""Crée l'arborescence technique de test sous *root*."""
(root / "Console").mkdir(parents=True)
(root / "Console" / "code.py").write_text('print("visible")\n', encoding="utf-8")
(root / "Console" / "notes.md").write_text("# Notes\n", encoding="utf-8")
atelier = root / "Workspaces" / "atelier"
(atelier / ".home").mkdir(parents=True)
(atelier / "note.md").write_text("# Atelier\n", encoding="utf-8")
(atelier / ".home" / "cache.md").write_text("# Cache du sandbox\n", encoding="utf-8")
venv = root / ".venv"
venv.mkdir()
(venv / "lib.py").write_text("# lib du venv\n", encoding="utf-8")
hermes = root / "Executeurs" / "Hermes"
(hermes / "missions").mkdir(parents=True)
(hermes / "data").mkdir(parents=True)
(hermes / "missions" / "mission.md").write_text("# Mission\n", encoding="utf-8")
(hermes / "data" / "config.md").write_text("# Profil de l'exécuteur\n", encoding="utf-8")
(hermes / ".env").write_text("CLE_FICTIVE=ne-pas-exposer\n", encoding="utf-8")
(root / "racine.md").write_text("# Racine\n", encoding="utf-8")
@pytest.fixture
def spark_dir(tmp_path: Path) -> Path:
root = tmp_path / "SparkDir"
root.mkdir()
_build_spark_tree(root)
return root
@pytest.fixture
def spark_client(spark_dir: Path):
"""TestClient avec le répertoire de test monté en vault DIR readonly.
Même patron que le fixture ``app_with_vault`` de conftest.py, mais en
DIR_N_* avec les trois nouvelles variables posées.
"""
os.environ["DIR_1_NAME"] = VAULT_NAME
os.environ["DIR_1_PATH"] = str(spark_dir)
os.environ["DIR_1_READONLY"] = "true"
os.environ["DIR_1_IGNORED_DIRS"] = ".home,.venv"
os.environ["DIR_1_IGNORED_PATHS"] = "Executeurs/*/data,Executeurs/*/.env"
os.environ["OBSIGATE_AUTH_ENABLED"] = "false"
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.indexer import build_index
from backend.main import app
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(build_index())
return TestClient(app)
# ── Parsing de la configuration ─────────────────────────────────────
class TestParsingConfig:
def test_dir_variables_parsees(self, monkeypatch, tmp_path):
monkeypatch.setenv("DIR_1_NAME", "SparkParse")
monkeypatch.setenv("DIR_1_PATH", str(tmp_path))
monkeypatch.setenv("DIR_1_READONLY", "true")
monkeypatch.setenv("DIR_1_IGNORED_DIRS", ".home, .venv ,__pycache__")
monkeypatch.setenv("DIR_1_IGNORED_PATHS", "Executeurs/*/data, Executeurs/*/.env")
from backend.indexer import load_vault_config
cfg = load_vault_config()["SparkParse"]
assert cfg["type"] == "DIR"
assert cfg["readonly"] is True
assert cfg["ignoredDirs"] == {".home", ".venv", "__pycache__"}
assert cfg["ignoredPaths"] == ["Executeurs/*/data", "Executeurs/*/.env"]
def test_dir_sans_variables_defauts(self, monkeypatch, tmp_path):
monkeypatch.setenv("DIR_1_NAME", "DirNu")
monkeypatch.setenv("DIR_1_PATH", str(tmp_path))
from backend.indexer import load_vault_config
cfg = load_vault_config()["DirNu"]
assert cfg["readonly"] is False
assert cfg["ignoredDirs"] == set()
assert cfg["ignoredPaths"] == []
# ── Helper chemin_exclu ─────────────────────────────────────────────
class TestCheminExclu:
CFG: ClassVar[dict] = {
"ignoredDirs": {".home", ".venv"},
"ignoredPaths": ["Executeurs/*/data", "Executeurs/*/.env"],
}
def test_nom_de_dossier_a_toute_profondeur(self):
from backend.services.vaults import chemin_exclu_dans_config
assert chemin_exclu_dans_config(self.CFG, "Workspaces/atelier/.home/cache.md")
assert chemin_exclu_dans_config(self.CFG, ".venv/lib.py")
assert chemin_exclu_dans_config(self.CFG, ".home")
def test_motif_chemin_et_sous_arbre(self):
from backend.services.vaults import chemin_exclu_dans_config
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/data")
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/data/config.md")
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Pi/data/x/y.py")
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/.env")
def test_non_exclus_et_tolerance(self):
from backend.services.vaults import chemin_exclu_dans_config
assert not chemin_exclu_dans_config(self.CFG, "Console/code.py")
assert not chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/missions/mission.md")
assert not chemin_exclu_dans_config({}, "Console/code.py")
assert not chemin_exclu_dans_config(None, "Console/code.py")
assert not chemin_exclu_dans_config(self.CFG, "")
# ── Navigation et lectures via l'API ────────────────────────────────
class TestNavigationEtLecture:
def test_browse_racine_cache_les_dossiers_ignores(self, spark_client):
resp = spark_client.get(f"/api/browse/{VAULT_NAME}")
assert resp.status_code == 200
names = {item["name"] for item in resp.json()["items"]}
assert {"Console", "Workspaces", "Executeurs", "racine.md"} <= names
assert ".venv" not in names
def test_browse_sous_dossier_cache_data(self, spark_client):
resp = spark_client.get(f"/api/browse/{VAULT_NAME}", params={"path": "Executeurs/Hermes"})
assert resp.status_code == 200
names = {item["name"] for item in resp.json()["items"]}
assert "missions" in names
assert "data" not in names
def test_browse_chemin_exclu_404(self, spark_client):
resp = spark_client.get(f"/api/browse/{VAULT_NAME}", params={"path": "Executeurs/Hermes/data"})
assert resp.status_code == 404
def test_vue_fichier_exclu_404(self, spark_client):
resp = spark_client.get(f"/api/file/{VAULT_NAME}", params={"path": "Executeurs/Hermes/data/config.md"})
assert resp.status_code == 404
def test_raw_exclu_404(self, spark_client):
resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Executeurs/Hermes/data/config.md"})
assert resp.status_code == 404
def test_raw_env_exclu_404(self, spark_client):
resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Executeurs/Hermes/.env"})
assert resp.status_code == 404
assert "CLE_FICTIVE" not in resp.text
def test_telechargement_env_exclu_404(self, spark_client):
resp = spark_client.get(f"/api/file/{VAULT_NAME}/download", params={"path": "Executeurs/Hermes/.env"})
assert resp.status_code == 404
assert "CLE_FICTIVE" not in resp.text
def test_fichier_non_exclu_lisible(self, spark_client):
resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Console/code.py"})
assert resp.status_code == 200
assert "visible" in resp.json()["raw"]
# ── Indexation et liste des chemins ─────────────────────────────────
class TestIndexation:
def test_index_ne_contient_aucun_exclu(self, spark_client):
from backend.indexer import get_vault_data
data = get_vault_data(VAULT_NAME)
assert data is not None
file_paths = {f["path"] for f in data.get("files", [])}
assert "Console/code.py" in file_paths
for p in file_paths:
segments = p.split("/")
assert ".home" not in segments and ".venv" not in segments
assert "data" not in segments[:-1] or not p.startswith("Executeurs/")
path_entries = {e["path"] for e in data.get("paths", [])}
assert "Executeurs/Hermes/data" not in path_entries
assert "Workspaces/atelier/.home" not in path_entries
def test_list_paths_filtre_les_exclus(self, spark_client):
from backend.services.search import list_paths
result = list_paths(VAULT_NAME)
paths = {r["path"] for r in result["results"]}
assert "Console/code.py" in paths
assert not any("/data/" in f"/{p}/" and p.startswith("Executeurs/") for p in paths)
assert not any(".home" in p.split("/") or ".venv" in p.split("/") for p in paths)
# ── Lecture seule ───────────────────────────────────────────────────
class TestLectureSeule:
def test_save_refuse_403(self, spark_client):
resp = spark_client.put(
f"/api/file/{VAULT_NAME}/save",
params={"path": "Console/code.py"},
json={"content": "print('modifié')\n"},
)
assert resp.status_code == 403
def test_creation_dossier_refusee_403(self, spark_client):
resp = spark_client.post(f"/api/directory/{VAULT_NAME}", json={"path": "NouveauDossier"})
assert resp.status_code == 403
def test_vaults_expose_readonly(self, spark_client):
resp = spark_client.get("/api/vaults")
assert resp.status_code == 200
entry = next(v for v in resp.json() if v["name"] == VAULT_NAME)
assert entry["type"] == "DIR"
assert entry["readonly"] is True