Compare commits

...
347 Commits
Author SHA1 Message Date
Spark 03a1c9f6f5 fix: entrée SSO — retour honoré à la restauration de session et nom du compte poussé à la barre (BUG-123, BUG-124)
CI / lint (pull_request) Successful in 1m24s
CI / security (pull_request) Successful in 17s
CI / test (pull_request) Successful in 3m38s
CI / build (pull_request) Successful in 12s
CI / e2e (pull_request) Failing after 15m36s
BUG-123 : /sso/authorize déposait l'utilisateur déjà connecté
sur l'accueil ObsiGate sans suivre le return_to — seul
saveToken() (login frais) l'honorait. initAuth() l'honore
désormais sur ses deux chemins de restauration au démarrage ;
les refresh en cours de session ne redirigent jamais.

BUG-124 : après un login SPA, le menu compte de la barre
affichait « Compte » jusqu'au rechargement. Barre commune
re-vendangée en v0.7.0 (DRACO_BAR.setUser, commons PR #15) ;
renderUserSection() et le fetch d'amorçage poussent le nom.

Garde : tests/test_draco_barre.py (nouveau test épinglé).
Suite complète : 1977 passed, 1 skipped ; ruff et mypy 0.
2026-11-08 17:49:27 +00:00
bruno 045abd6a3c Merge pull request 'ObsiGate 2.86.1 — barre v0.6.0 : entrée « Administration » du menu compte (fusion #142)' (#72) from menu-compte-admin-v060 into main
CI / lint (push) Successful in 1m19s
CI / security (push) Successful in 19s
CI / test (push) Successful in 3m37s
CI / build (push) Successful in 11s
CI / e2e (push) Failing after 15m5s
2026-11-06 15:27:43 -05:00
Spark 06855937bd feat: barre v0.6.0 — entrée « Administration » du menu compte affirmée par le rôle global (fusion #142)
CI / lint (pull_request) Successful in 1m31s
CI / security (pull_request) Successful in 29s
CI / test (pull_request) Successful in 3m40s
CI / build (pull_request) Successful in 12s
CI / e2e (pull_request) Failing after 14m47s
2026-11-06 20:21:31 +00:00
bruno 854ff0f904 Merge pull request 'ObsiGate 2.86.0 — BUG-122 : déconnexion globale navigable GET /sso/logout (fusion #141)' (#71) from fix/deconnexion-globale-bug-122 into main
CI / lint (push) Successful in 1m21s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m40s
CI / build (push) Successful in 10s
CI / e2e (push) Failing after 15m8s
2026-11-06 14:26:43 -05:00
Spark 5a5d7a5c59 fix: brancher aussi la barre d'ObsiGate sur /sso/logout (BUG-122, fusion #141)
CI / lint (pull_request) Successful in 1m21s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 3m37s
CI / build (pull_request) Successful in 11s
CI / e2e (pull_request) Failing after 17m11s
2026-11-06 19:07:29 +00:00
Spark b8949b2ddc fix: point d'entrée navigable de la déconnexion globale GET /sso/logout (BUG-122, fusion #141) — 2.86.0
CI / lint (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 3m40s
CI / build (pull_request) Successful in 32s
CI / e2e (pull_request) Failing after 15m40s
2026-11-06 18:56:04 +00:00
bruno 15d015e59b Merge pull request 'ObsiGate 2.85.1 — HUB-19 : propagation des rôles du Hub (consommatrice, registre D3(a)) (#228)' (#69) from hub19-roles-obsigate-2-85-1 into main
CI / lint (push) Successful in 1m14s
CI / security (push) Successful in 19s
CI / test (push) Successful in 3m34s
CI / build (push) Successful in 11s
CI / e2e (push) Failing after 15m26s
2026-11-06 11:31:25 -05:00
Spark dcf847e2fd feat: HUB-19 — rôles du Hub consommés par ObsiGate (registre D3(a)) #228
CI / lint (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 21s
CI / test (pull_request) Failing after 3m39s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-11-06 16:30:30 +00:00
bruno a3f76e9b0c Merge pull request 'ObsiGate 2.85.0 — HUB-22 : API machine des sessions draco (liste et révocation, #227)' (#68) from hub22-sessions-machine into main
CI / lint (push) Failing after 11s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 24s
2026-11-06 11:25:26 -05:00
Spark c157a22668 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227)
CI / lint (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 17s
CI / test (pull_request) Successful in 3m34s
CI / build (pull_request) Successful in 17s
CI / e2e (pull_request) Failing after 16m29s
2026-11-06 11:08:04 -05:00
Spark cc3707be7a feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:08:01 -05:00
Spark 75c6f61235 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:58 -05:00
Spark 8d7692c6df feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:55 -05:00
Spark 318a20c652 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:51 -05:00
Spark 006cbeed64 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:46 -05:00
Spark 92ad47fc99 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:43 -05:00
Spark 84648591ea feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:39 -05:00
Spark 308f99cf63 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:35 -05:00
Spark 3bfda066ab feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:31 -05:00
Spark 0230f4fa72 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:30 -05:00
Spark 9f6f366870 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:27 -05:00
Spark 44a8154cae feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:24 -05:00
Spark 222cd0b562 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227) 2026-11-06 11:07:22 -05:00
Spark 5127510509 revert: HUB-22 — retrait du fichier poussé par erreur sur main (le PUT Contents a ignoré new_branch_name ; la livraison repart en branche hub22-sessions-machine, #227)
CI / lint (push) Successful in 1m20s
CI / security (push) Successful in 16s
CI / test (push) Successful in 4m42s
CI / build (push) Successful in 13s
CI / e2e (push) Failing after 14m59s
2026-11-06 11:06:56 -05:00
Spark 3876f0e9c3 feat: HUB-22 — API machine des sessions draco : liste et révocation des sessions (2.85.0, #227)
CI / lint (push) Successful in 1m18s
CI / security (push) Successful in 16s
CI / test (push) Successful in 3m35s
CI / build (push) Successful in 14s
CI / e2e (push) Failing after 15m32s
2026-11-06 11:06:00 -05:00
bruno c8d2eb19db Merge pull request 'ObsiGate 2.84.1 — HUB-18 : thème commun, barre v0.5.0 + initialisation thème Hub (fusion #132)' (#67) from hub18-theme-commun into main
CI / lint (push) Successful in 1m34s
CI / security (push) Successful in 28s
CI / test (push) Successful in 4m49s
CI / build (push) Successful in 10s
CI / e2e (push) Failing after 14m54s
2026-11-06 09:45:58 -05:00
Spark 2943e40e81 feat: HUB-18 thème commun — barre v0.5.0 + init thème Hub (2.84.1, fusion #132)
CI / lint (pull_request) Successful in 1m16s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 3m38s
CI / build (pull_request) Successful in 13s
CI / e2e (pull_request) Failing after 15m2s
2026-11-06 14:22:45 +00:00
bruno 128deb248d Merge pull request 'ObsiGate 2.84.0 — HUB-13 : API d'administration des comptes machine-à-machine (fusion #127)' (#66) from hub13-api-admin-comptes-2-84-0 into main
CI / lint (push) Successful in 1m15s
CI / security (push) Successful in 15s
CI / test (push) Successful in 3m45s
CI / build (push) Successful in 10s
CI / e2e (push) Successful in 14m48s
2026-11-06 08:40:24 -05:00
Spark 151d9faeab chore: relance de la vérification de la PR (commit vide, aucun changement de code)
CI / lint (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 3m46s
CI / build (pull_request) Successful in 11s
CI / e2e (pull_request) Successful in 15m53s
2026-11-06 13:34:29 +00:00
bruno c335f57ea2 Merge PR #66 : ObsiGate 2.84.0 — HUB-13 API admin des comptes (go Bruno)
CI / lint (push) Successful in 1m21s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m49s
CI / build (push) Successful in 15s
CI / e2e (push) Successful in 15m8s
2026-11-05 22:30:21 -05:00
Spark c27fd977a8 ObsiGate 2.84.0 : version et changelog (HUB-13, fusion #127) — VERSION + fichiers dérivés synchronisés par scripts/bump_version.py
CI / lint (pull_request) Successful in 1m23s
CI / security (pull_request) Successful in 23s
CI / test (pull_request) Successful in 3m34s
CI / build (pull_request) Successful in 1m32s
CI / e2e (pull_request) Successful in 15m8s
2026-11-06 00:58:19 +00:00
Spark 65d01f91b8 ObsiGate 2.84.0 : API d'administration des comptes machine-à-machine (HUB-13, fusion #127, #226) — routes /api/admin/machine/comptes protégées par DRACO_ADMIN_MACHINE_KEY, révocation des jti draco à la désactivation, must_change_password, journalisation d'audit 2026-11-06 00:58:16 +00:00
bruno 0427c00015 Merge pull request 'ObsiGate 2.83.5 — barre v0.4.1 : correctif des icônes invisibles du menu compte (#125)' (#65) from hub12-barre-v041 into main
CI / lint (push) Successful in 2m28s
CI / security (push) Successful in 25s
CI / test (push) Successful in 3m34s
CI / build (push) Successful in 9s
CI / e2e (push) Successful in 16m51s
2026-11-05 02:09:20 -05:00
Spark b83de53447 ObsiGate 2.83.5 : barre v0.4.1 — menu compte resserré à 232 px, re-vendange verbatim (fusion #125)
CI / lint (pull_request) Successful in 1m21s
CI / security (pull_request) Successful in 28s
CI / test (pull_request) Successful in 5m18s
CI / build (pull_request) Successful in 15s
CI / e2e (pull_request) Successful in 16m40s
2026-11-05 07:08:02 +00:00
Spark 5eba3e2558 ObsiGate 2.83.5 : version et changelog (barre v0.4.1, fusion #125) — VERSION + fichiers dérivés synchronisés par scripts/bump_version.py
CI / lint (pull_request) Successful in 1m28s
CI / security (pull_request) Successful in 17s
CI / test (pull_request) Successful in 3m57s
CI / build (pull_request) Successful in 10s
CI / e2e (pull_request) Successful in 17m0s
2026-11-05 05:53:14 +00:00
Spark 10aa88e03c ObsiGate 2.83.5 : barre v0.4.1 — correctif des icônes invisibles du menu compte, vendange verbatim (fusion #125) + garde 2026-11-05 05:53:05 +00:00
bruno e0e0f9af0f Merge pull request 'ObsiGate 2.83.4 — barre v0.4.0 : icônes monoline dans le menu compte (HUB-11)' (#64) from hub11-barre-v040 into main
CI / lint (push) Successful in 1m24s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m35s
CI / build (push) Successful in 10s
CI / e2e (push) Successful in 16m38s
2026-11-05 00:33:34 -05:00
Spark 35b828a4c5 ObsiGate 2.83.4 : version et changelog (barre v0.4.0, HUB-11) — VERSION + fichiers dérivés synchronisés par scripts/bump_version.py
CI / lint (pull_request) Successful in 1m19s
CI / security (pull_request) Successful in 21s
CI / test (pull_request) Successful in 3m37s
CI / build (pull_request) Successful in 12s
CI / e2e (pull_request) Successful in 16m51s
2026-11-05 05:03:01 +00:00
Spark 6ebb3b3eae ObsiGate 2.83.4 : barre v0.4.0 — icônes monoline du menu compte, vendange verbatim (HUB-11) + garde 2026-11-05 05:02:10 +00:00
bruno 569d0e33a6 Merge pull request 'ObsiGate 2.83.3 : barre v0.3.0 — liens Hub du menu compte (HUB-1)' (#63) from hub1-barre-v030 into main
CI / lint (push) Successful in 1m31s
CI / security (push) Successful in 22s
CI / test (push) Successful in 3m55s
CI / build (push) Successful in 11s
CI / e2e (push) Successful in 17m28s
2026-11-04 19:48:18 -05:00
Spark 1d1ce5a848 ObsiGate 2.83.3 : barre v0.3.0, liens Hub du menu compte (VERSION + fichiers dérivés synchronisés)
CI / lint (pull_request) Successful in 1m18s
CI / security (pull_request) Successful in 18s
CI / test (pull_request) Successful in 3m45s
CI / build (pull_request) Successful in 12s
CI / e2e (pull_request) Successful in 16m20s
2026-11-04 21:57:41 +00:00
Spark f04369343d ObsiGate 2.83.3 : barre v0.3.0, liens Hub du menu compte (tests) 2026-11-04 21:57:41 +00:00
Spark 8290abd675 ObsiGate 2.83.3 : barre v0.3.0, liens Hub du menu compte (frontend + draco_config) 2026-11-04 21:57:41 +00:00
bruno ea60d7e172 Merge pull request 'ObsiGate 2.83.2 : recherche fédérée — ShaarIt, 5e source de « Rechercher partout »' (#62) from recherche-shaarit into main
CI / lint (push) Successful in 1m29s
CI / security (push) Successful in 18s
CI / test (push) Successful in 3m48s
CI / build (push) Successful in 46s
CI / e2e (push) Successful in 17m17s
2026-11-04 12:23:55 -05:00
Spark 606d59f483 ObsiGate 2.83.2 : recherche fédérée — ShaarIt, 5e source de « Rechercher partout »
CI / lint (pull_request) Successful in 1m25s
CI / security (pull_request) Successful in 20s
CI / test (pull_request) Successful in 4m4s
CI / build (pull_request) Failing after 10m20s
CI / e2e (pull_request) Skipped
- frontend/js/recherche.js re-vendangé verbatim depuis commons
  v0.2.1 (source canonique ui/recherche/) : source ShaarIt (bases,
  onglet, normaliseur — lien profond {base}shaare/<shorturl>).
- frontend/recherche.html : data-base-shaarit (jeton URL_SHAARIT),
  onglet et section « ShaarIt — bookmarks ».
- backend/recherche_cors.py : origines ShaarIt prod/dev + clé
  sœur ; backend/main.py : origine ShaarIt dans le connect-src
  CSP de la page /recherche.
- Version synchronisée par scripts/bump_version.py --set 2.83.2
  (VERSION, package.json, desktop, README, ROADMAP, CHANGELOG).
- Tests : recherche.test.mjs 67 assertions (5 sources),
  test_recherche_cors allowlist 12 origines ; suite backend
  complète 1916 passés / 0 échec, ruff et mypy verts.
2026-11-04 17:23:38 +00:00
bruno a046f286e6 Merge pull request 'ObsiGate 2.83.1 : barre v0.2.0 — onglet ShaarIt + garde data-theme' (#61) from barre-v020-2-83-1 into main
CI / lint (push) Successful in 1m59s
CI / security (push) Successful in 15s
CI / test (push) Successful in 3m55s
CI / build (push) Successful in 10s
CI / e2e (push) Successful in 22m11s
2026-11-04 11:16:29 -05:00
bruno 0a92a55efa ObsiGate 2.83.1 : barre v0.2.0, onglet ShaarIt + garde data-theme (VERSION + fichiers dérivés synchronisés)
CI / lint (pull_request) Successful in 1m19s
CI / security (pull_request) Successful in 22s
CI / test (pull_request) Successful in 3m29s
CI / build (pull_request) Successful in 15s
CI / e2e (pull_request) Successful in 16m49s
2026-11-04 03:38:18 +00:00
bruno 3c383559f4 ObsiGate 2.83.1 : barre v0.2.0, onglet ShaarIt + garde data-theme (tests/test_draco_config.py) 2026-11-04 03:38:18 +00:00
bruno 6253c7558a ObsiGate 2.83.1 : barre v0.2.0, onglet ShaarIt + garde data-theme (tests/test_draco_barre.py) 2026-11-04 03:38:18 +00:00
bruno 6835607f66 ObsiGate 2.83.1 : barre v0.2.0, onglet ShaarIt + garde data-theme (.env.example) 2026-11-04 03:38:18 +00:00
bruno 478749566a ObsiGate 2.83.1 : barre v0.2.0, onglet ShaarIt + garde data-theme (backend/main.py) 2026-11-04 03:38:18 +00:00
bruno d00c261ff1 ObsiGate 2.83.1 : barre v0.2.0, onglet ShaarIt + garde data-theme (backend/draco_config.py) 2026-11-04 03:38:18 +00:00
bruno 646a4977f5 ObsiGate 2.83.1 : barre v0.2.0, onglet ShaarIt + garde data-theme (frontend/recherche.html) 2026-11-04 03:38:18 +00:00
bruno 8683e1d724 ObsiGate 2.83.1 : barre v0.2.0, onglet ShaarIt + garde data-theme (frontend/index.html) 2026-11-04 03:38:18 +00:00
bruno 5a7f1b9bcf ObsiGate 2.83.1 : barre v0.2.0, onglet ShaarIt + garde data-theme (frontend/img/logos/shaarit.svg) 2026-11-04 03:38:18 +00:00
bruno 14089ff59e ObsiGate 2.83.1 : barre v0.2.0, onglet ShaarIt + garde data-theme (frontend/draco-barre.js) 2026-11-04 03:38:18 +00:00
bruno 0b0639ce61 ObsiGate 2.83.1 : barre v0.2.0, onglet ShaarIt + garde data-theme (frontend/draco-barre.css) 2026-11-04 03:38:18 +00:00
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
bruno dcec510451 fix(sidebar): CSS composer console, statut dans le titre, pied épuré
CI / lint (pull_request) Failing after 1s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 08:49:05 -04:00
bruno cb468b0f3c fix(sidebar): style console, statut dans le titre, composer console, synchro domaine 2026-10-28 08:48:54 -04:00
bruno 6713510ce9 Merge pull request 'fix(ci): tag OpenAPI 'Coffres Secrets' declare + compteur de themes 15 -> 16' (#34) from fix/ci-tests-openapi-themes into main
CI / lint (push) Successful in 2m57s
CI / test (push) Successful in 5m37s
CI / build (push) Successful in 3m15s
CI / e2e (push) Successful in 18m7s
CI / security (push) Successful in 1m58s
2026-10-28 08:27:31 -04:00
bruno bf5b32de16 Merge pull request 'feat(ui): icone coffre-fort SVG pour le noeud Secrets' (#33) from fix/icone-secrets-coffre-fort into main
CI / security (push) Successful in 1m58s
CI / lint (push) Successful in 2m55s
CI / test (push) Failing after 1s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 08:25:45 -04:00
bruno 2a1780b43d docs: commentaire 15 -> 16 themes integres (meme compteur obsolete que test_themes.py)
CI / security (pull_request) Failing after 0s
CI / lint (pull_request) Successful in 3m11s
CI / test (pull_request) Failing after 5m8s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 08:25:38 -04:00
bruno be05ec99b5 fix(tests): test_theme_count 15 -> 16 (theme spark ajoute en 2.80.2) 2026-10-28 08:25:35 -04:00
bruno 3a42bc034d fix(tests): declare le tag OpenAPI 'Coffres Secrets' (test_used_tags_are_declared) 2026-10-28 08:25:32 -04:00
bruno 27e7664ef0 feat(ui): dimensionnement de l'icone coffre-fort du noeud Secrets
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Successful in 1m56s
2026-10-28 08:24:23 -04:00
bruno ccbf1d3269 feat(ui): icone coffre-fort SVG pour le noeud Secrets (remplace le cadenas emoji) 2026-10-28 08:24:16 -04:00
bruno 0ca14739f9 Merge pull request 'fix(lint): tri des imports dans backend/routers/coffres.py (I001)' (#32) from fix/ruff-coffres-imports into main
CI / security (push) Successful in 1m56s
CI / lint (push) Successful in 3m14s
CI / test (push) Failing after 5m5s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 08:16:47 -04:00
bruno d9192ef355 fix(lint): tri des imports dans backend/routers/coffres.py (I001)
CI / lint (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Successful in 1m59s
2026-10-28 08:13:35 -04:00
bruno 4c9bfd576c Merge pull request 'chore(release): version 2.81.0 (Coffre Secrets Phases 2-5)' (#31) from fix/version-2.81.0 into main
CI / security (push) Failing after 0s
CI / lint (push) Failing after 1m50s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 00:16:49 -04:00
bruno 20898cade7 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5)
CI / lint (pull_request) Failing after 0s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-28 00:16:25 -04:00
bruno 2c12639499 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:21 -04:00
bruno 7d2b2067ef chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:19 -04:00
bruno 0b52e6ee86 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:16 -04:00
bruno cafac69008 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:12 -04:00
bruno 871875b8e5 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:10 -04:00
bruno e5ae3e48d6 chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:08 -04:00
bruno 76de3dd75e chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:05 -04:00
bruno 687427381d chore(release): bump version 2.81.0 (Coffre Secrets Phases 2-5) 2026-10-28 00:16:03 -04:00
bruno e418d87802 Merge pull request '[Coffre Secrets] Phase 5 — Documentation de deploiement et mise en service' (#30) from feat/coffre-phase5-deploiement into main
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 00:15:09 -04:00
bruno a1f3690085 Merge pull request '[Coffre Secrets] Phase 2 — Migration api_keys.json, sauvegarde/restauration KEK, contrat distribution, consommateurs + correctif UI modale' (#29) from feat/coffre-phase2 into main
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-28 00:15:06 -04:00
bruno 0a7bed10bf Phase 5 (issue #23) : correction numerotation etape lecteurs internes (4, pas 5)
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Failing after 0s
2026-10-28 00:11:20 -04:00
bruno 32996ce9aa Phase 5 (issue #23) : deploiement et mise en service du coffre Secrets 2026-10-28 00:10:51 -04:00
bruno b42265b41e Phase 2 coffre: correctif modale (max-height viewport + scroll interne)
CI / security (pull_request) Failing after 1s
CI / lint (pull_request) Failing after 1m46s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:45:44 -04:00
bruno 0fc7654f6c Phase 2 coffre: cles i18n import (en) 2026-10-27 23:45:38 -04:00
bruno effad97472 Phase 2 coffre: cles i18n import (fr) 2026-10-27 23:45:33 -04:00
bruno 53d98bfffe Phase 2 coffre: variables OBSIGATE_SECRETS_ENABLED/KEK documentees (sans valeur) 2026-10-27 23:45:20 -04:00
bruno 62aa6079be Phases 3-4 coffre: contrat de distribution Spark et consommateurs 2026-10-27 23:45:16 -04:00
bruno 7d48f65ff9 Phase 2 coffre: documentation sauvegarde/restauration KEK 2026-10-27 23:45:13 -04:00
bruno 3acadf6ad7 Phase 2 coffre: bouton Importer l'existant (import puis purge confirmee) 2026-10-27 23:45:10 -04:00
bruno 5492f99f06 Phase 2 coffre: tests migration, restauration vierge, Spark, consommateurs 2026-10-27 23:45:05 -04:00
bruno 71221e055b Phase 2 coffre: migration, consommateur+octrois, contexte Spark imbrique 2026-10-27 23:44:58 -04:00
bruno 86330e8f65 Phase 2 coffre: module migration api_keys.json -> coffre d'instance (import + purge confirmee) 2026-10-27 23:44:43 -04:00
bruno f3b507b8d9 Merge pull request '[Theme] Nouveau theme Spark (dark/light, identique a la Console Spark) et defaut = Spark dark' (#28) from feat/theme-spark into main
CI / security (push) Failing after 0s
CI / lint (push) Successful in 3m6s
CI / test (push) Failing after 0s
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-27 23:25:10 -04:00
bruno c01c7e862d Merge pull request '[fix] Mypy Coffre Secrets (3 erreurs) + version 2.80.2' (#27) from fix/mypy-coffre-2.80.2 into main
CI / lint (push) Failing after 0s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-27 23:24:44 -04:00
bruno 99cd765997 [fix] CHANGELOG 2.80.2 : bouton Nouveau secret + plancher msgpack
CI / lint (pull_request) Failing after 0s
CI / security (pull_request) Failing after 0s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:20:41 -04:00
bruno 5266506b56 [fix] Securite : plancher msgpack>=1.2.3 (CVE-2026-114613, pip-audit bloquant)
CI / lint (pull_request) Failing after 1s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
CI / security (pull_request) Successful in 1m56s
2026-10-27 23:20:35 -04:00
bruno 682a5030d3 [fix] Coffre Secrets : valeurField attache l'oeil a wrap avant insertBefore (NotFoundError bouton Nouveau secret)
CI / security (pull_request) Failing after 0s
CI / lint (pull_request) Successful in 3m19s
CI / test (pull_request) Failing after 1m47s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:20:30 -04:00
bruno fc3d847d85 [fix] mypy Coffre + bump 2.80.2 : CHANGELOG.md
CI / security (pull_request) Failing after 2m29s
CI / lint (pull_request) Successful in 2m55s
CI / test (pull_request) Failing after 5m10s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 23:10:51 -04:00
bruno 82756030c6 [fix] mypy Coffre + bump 2.80.2 : docs/ROADMAP.md 2026-10-27 23:10:48 -04:00
bruno 0352451ead [fix] mypy Coffre + bump 2.80.2 : README.fr.md 2026-10-27 23:10:44 -04:00
bruno 593cf492c3 [fix] mypy Coffre + bump 2.80.2 : README.md 2026-10-27 23:10:41 -04:00
bruno 0b8ad8b0f3 [fix] mypy Coffre + bump 2.80.2 : desktop/Cargo.lock 2026-10-27 23:10:39 -04:00
bruno 111d927ece [fix] mypy Coffre + bump 2.80.2 : desktop/Cargo.toml 2026-10-27 23:10:36 -04:00
bruno 2dae023ee3 [fix] mypy Coffre + bump 2.80.2 : desktop/tauri.conf.json 2026-10-27 23:10:34 -04:00
bruno b12122f78a [fix] mypy Coffre + bump 2.80.2 : package.json 2026-10-27 23:10:31 -04:00
bruno 0c7b3162c8 [fix] mypy Coffre + bump 2.80.2 : VERSION 2026-10-27 23:10:29 -04:00
bruno d4d050297b [fix] mypy Coffre + bump 2.80.2 : backend/coffre_store.py 2026-10-27 23:10:27 -04:00
bruno 8d768bf9d5 [fix] mypy Coffre + bump 2.80.2 : backend/coffre_crypto.py 2026-10-27 23:10:24 -04:00
bruno 47ac8bf6c0 Merge pull request '[fix] Version 2.80.1 synchronisee + corrections ruff (60 erreurs CI)' (#26) from fix/version-2.80.1-ruff into main
CI / security (push) Failing after 1s
CI / lint (push) Failing after 2m10s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-27 23:00:13 -04:00
bruno 01572736c0 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/webhooks.py
CI / security (pull_request) Successful in 1m57s
CI / lint (pull_request) Failing after 2m7s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 22:58:14 -04:00
bruno b6e5f01748 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/watcher.py 2026-10-27 22:58:11 -04:00
bruno 90da0ac990 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/tools/audit.py 2026-10-27 22:58:08 -04:00
bruno bc26d1506b [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/share.py 2026-10-27 22:58:05 -04:00
bruno 769e3ca559 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/services/vaults.py 2026-10-27 22:58:02 -04:00
bruno b5d789f02a [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/services/recent.py 2026-10-27 22:57:59 -04:00
bruno c8dce25e0d [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/services/backups.py 2026-10-27 22:57:56 -04:00
bruno b322e2d24a [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/scheduler.py 2026-10-27 22:57:53 -04:00
bruno 414ec69c0a [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/routers/realtime.py 2026-10-27 22:57:50 -04:00
bruno f5ca656c04 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/routers/backups.py 2026-10-27 22:57:47 -04:00
bruno e158232aba [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/push.py 2026-10-27 22:57:44 -04:00
bruno 632871ac24 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/notify.py 2026-10-27 22:57:40 -04:00
bruno 7af7b2c8c2 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/indexer.py 2026-10-27 22:57:36 -04:00
bruno 97132770e4 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/guide_export.py 2026-10-27 22:57:32 -04:00
bruno 683ad3ef94 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/export.py 2026-10-27 22:57:29 -04:00
bruno 604383f51c [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/coffre_store.py 2026-10-27 22:57:25 -04:00
bruno bdeb2f00b3 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/coffre_crypto.py 2026-10-27 22:57:21 -04:00
bruno 8688875fae [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/bookslm_routes.py 2026-10-27 22:57:17 -04:00
bruno a42271c7de [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/auth/user_store.py 2026-10-27 22:57:13 -04:00
bruno cf4685eaf1 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/auth/router.py 2026-10-27 22:57:10 -04:00
bruno 5023fb6774 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/audit.py 2026-10-27 22:57:06 -04:00
bruno 04eb70cbe4 [fix] Corrections ruff CI (UP017/UP041/UP012/I001/F401/FURB162) : backend/admin.py 2026-10-27 22:57:01 -04:00
bruno 4dc010f54d [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : CHANGELOG.md 2026-10-27 22:56:57 -04:00
bruno f7e36bc44e [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : docs/ROADMAP.md 2026-10-27 22:56:53 -04:00
bruno a9ac1c06a7 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : README.fr.md 2026-10-27 22:56:48 -04:00
bruno 9bf6e7492f [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : README.md 2026-10-27 22:56:43 -04:00
bruno 14070cef43 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : desktop/Cargo.lock 2026-10-27 22:56:37 -04:00
bruno 6b1abbb50e [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : desktop/Cargo.toml 2026-10-27 22:56:33 -04:00
bruno 46c641ebc5 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : desktop/tauri.conf.json 2026-10-27 22:56:30 -04:00
bruno 2d036f5126 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : package.json 2026-10-27 22:56:27 -04:00
bruno 2c69e357b8 [fix] Bump VERSION 2.79.0 -> 2.80.1 (synchronisation bump_version.py) : VERSION 2026-10-27 22:56:25 -04:00
bruno ba81f22d40 Merge pull request '[Coffre Secrets] Phase 1 complète : chiffrement, API et UI (1a + 1b + 1c)' (#25) from feat/coffre-secrets-phase1 into main
CI / lint (push) Failing after 1m47s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
CI / security (push) Successful in 1m58s
2026-10-27 22:29:09 -04:00
bruno 070add4e7b [Thème Spark] Anti-FOUC admin aligné sur fond/texte Spark
CI / security (pull_request) Failing after 0s
CI / lint (pull_request) Successful in 3m10s
CI / test (pull_request) Failing after 5m22s
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 22:10:14 -04:00
bruno 847b3225b2 [Thème Spark] Splash et meta theme-color alignés sur Spark dark 2026-10-27 22:10:11 -04:00
bruno 3ae5c7efac [Thème Spark] Bloc :root de secours aligné sur la palette dark Spark 2026-10-27 22:10:07 -04:00
bruno 4966c27548 [Thème Spark] Ajout du thème 'spark' (dark/light, 39 variables) et défaut spark/dark 2026-10-27 22:10:04 -04:00
bruno 06c12d0383 [Coffre Secrets] Phase 1c UI : cles i18n coffres.* (en)
CI / security (pull_request) Failing after 2s
CI / lint (pull_request) Failing after 1m55s
CI / test (pull_request) Skipped
CI / build (pull_request) Skipped
CI / e2e (pull_request) Skipped
2026-10-27 22:08:23 -04:00
bruno 69f3968d4a [Coffre Secrets] Phase 1c UI : cles i18n coffres.* (fr) 2026-10-27 22:08:20 -04:00
bruno d5ae66466a [Coffre Secrets] Phase 1c UI : styles vue Secrets et menu contextuel 2026-10-27 22:08:16 -04:00
bruno 2dc8be3127 [Coffre Secrets] Phase 1c UI : noeud virtuel Secrets sous Home-user dans sidebar.js (patron Partage #196) 2026-10-27 22:08:05 -04:00
bruno 040e80a36d [Coffre Secrets] Phase 1c UI : nouveau module frontend/js/coffres.js (vue Secrets, modales, copie manuelle) 2026-10-27 22:08:00 -04:00
bruno bd6b79fb41 [Coffre Secrets] Phase 1a/1b : tests crypto, store, autorisations, distribution 2026-10-27 21:45:58 -04:00
bruno 581a31161c [Coffre Secrets] Phase 1a : dependance cryptography pour AES-GCM 2026-10-27 21:45:56 -04:00
bruno 5f8f93ba6c [Coffre Secrets] Phase 1b : montage du routeur coffres a cote de config_router 2026-10-27 21:45:53 -04:00
bruno 40b3e9c5dd [Coffre Secrets] Phase 1b : routeur /api/coffres, autorisations, octrois, distribution 2026-10-27 21:45:40 -04:00
bruno 2ffc219f47 [Coffre Secrets] Phase 1b : log_coffre_event dans audit.log (sans valeur) 2026-10-27 21:45:38 -04:00
bruno 02394d3278 [Coffre Secrets] Phase 1a : store coffres/valeurs/octrois/consommateurs (0600, atomique, RLock) 2026-10-27 21:45:35 -04:00
bruno ff1ccca3cd [Coffre Secrets] Phase 1a : chiffrement par enveloppe AES-256-GCM (KEK/DEK) 2026-10-27 21:45:32 -04:00
Spark fddbaec82e feat: panneau Spark — version du service d'agents visible en mode sidebar #225
CI / lint (push) Failing after 1s
CI / security (push) Failing after 0s
CI / test (push) Skipped
CI / build (push) Skipped
CI / e2e (push) Skipped
2026-10-26 16:56:28 +00:00
Spark 8e073438a3 fix: URLs sœurs dérivées de l'environnement de l'agent — le menu inter-apps ne pointe plus la prod depuis la dev BUG-120
CI / security (push) Successful in 1m55s
CI / lint (push) Successful in 3m5s
CI / test (push) Successful in 5m23s
CI / build (push) Successful in 1m46s
CI / e2e (push) Successful in 18m9s
2026-10-26 00:06:16 +00:00
Spark 6972e9ec87 fix: planchers de sécurité pip-audit — brotli, filelock, opentelemetry-api, pypdf #224
CI / security (push) Successful in 2m42s
CI / lint (push) Successful in 3m22s
CI / test (push) Successful in 5m34s
CI / build (push) Successful in 2m48s
CI / e2e (push) Successful in 19m15s
2026-10-25 21:02:57 +00:00
Spark f29a22886a feat: URLs publiques de l'écosystème par configuration — dev/prod sans noms en dur #223
CI / lint (push) Successful in 3m11s
CI / security (push) Failing after 1m55s
CI / test (push) Successful in 5m23s
CI / build (push) Successful in 2m35s
CI / e2e (push) Successful in 18m24s
2026-10-25 18:10:59 +00:00
Spark 287afa1537 fix: CSP — frame-src autorise la console du service d'agents (mode console du panneau Spark) BUG-119
CI / lint (push) Successful in 3m5s
CI / security (push) Failing after 1m49s
CI / test (push) Successful in 5m19s
CI / build (push) Successful in 1m36s
CI / e2e (push) Successful in 18m9s
2026-10-24 03:10:34 +00:00
Spark b0ac6dfdb9 docs: #222 livré (2.77.0) — statut Roadmap
CI / lint (push) Successful in 2m58s
CI / security (push) Failing after 1m46s
CI / test (push) Successful in 5m10s
CI / build (push) Successful in 1m39s
CI / e2e (push) Successful in 18m2s
2026-10-23 04:13:51 +00:00
Spark 7f06606300 feat: panneau Spark V3 — la console en surimpression pleine page #222 2026-10-23 04:13:37 +00:00
Spark 985af3a294 docs: #221 livré (2.76.1) — statut Roadmap
CI / lint (push) Successful in 2m54s
CI / security (push) Failing after 1m40s
CI / test (push) Successful in 4m59s
CI / build (push) Failing after 11m14s
CI / e2e (push) Skipped
2026-10-22 21:21:27 +00:00
124 changed files with 15232 additions and 472 deletions
+50
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
@@ -104,3 +114,43 @@ DEEPSEEK_MODEL=deepseek-chat
# OBSIGATE_GITEA_URL=https://git.example.net
# OBSIGATE_GITEA_TOKEN=
# OBSIGATE_GITHUB_TOKEN=
# ── Fusion / dev-prod — URL publiques de l'écosystème (#223) ──
# Aucun nom public n'est figé dans le code : ces variables portent les
# URL des autres applications. Valeurs par défaut (si absentes) = noms
# OFFICIELS de la production. Depuis BUG-120, les URL sœurs se dérivent
# de l'hôte de l'agent (agent<SUFFIXE>.dracodev.net, lu via
# AGENT_EVENEMENTS_URL / DRACO_AGENT_URL) : en dev, rien d'autre à
# déclarer pour obtenir les noms en -dev. Une variable explicite
# reste prioritaire sur la dérivation, par exemple :
# DRACO_TIKTAK_URL=https://tiktak-dev.dracodev.net
# DRACO_AGENT_URL=https://agent.dracodev.net
# DRACO_OBSIGATE_URL=https://og.dracodev.net
# DRACO_FLOWDECK_URL=https://flowdeck.dracodev.net
# DRACO_TIKTAK_URL=https://tiktak.dracodev.net
# DRACO_KANBANTE_URL=https://kanbante.dracodev.net
# DRACO_SHAARIT_URL=https://shaarit.dracodev.net
# DRACO_HUB_URL=https://hub.dracodev.net
# DRACO_GITEA_URL=https://git.dracodev.net
# ── Fusion — émission d'événements vers le service d'agents (#220) ──
# URL complète de réception ; à défaut, dérivée de DRACO_AGENT_URL.
# AGENT_EVENEMENTS_URL=https://agent.dracodev.net/api/v1/evenements
# AGENT_EVENEMENTS_CLE=<clé propre à ObsiGate, jamais commitée>
# AGENT_EVENEMENTS_DOMAINES={"NomDuVault": "famille"}
# ── Fusion — émettrice SSO draco_session (#216/#217) ──
# DRACO_SESSION_SECRET=<secret partagé, jamais commité>
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
# DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée>
# ── Coffre Secrets ObsiGate (Phases 1–2, voir docs/COFFRE-sauvegarde-restauration.md) ──
# Activation (défaut : désactivé — toutes les routes répondent 503 sinon) :
# OBSIGATE_SECRETS_ENABLED=true
# Clé maître 256 bits, base64 de 32 octets, rendue une seule fois par
# POST /api/coffres/admin/generer-kek ; à sauvegarder HORS dépôt, HORS note :
# OBSIGATE_KEK=<kek-base64-32-octets>
# … ou fichier 0600, hors dépôt et hors arborescence des vaults :
# OBSIGATE_KEK_FILE=/chemin/hors/depot/kek.txt
# Aucune autre variable ne doit contenir une valeur de secret du coffre.
+36 -18
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
@@ -180,7 +180,13 @@ jobs:
# PYSEC-2026-3910 / PYSEC-2026-3911 (pypdf, DoS de ressources sur
# l'extraction de texte et la lecture d'outlines — donc atteignables
# via backend/pdf_reader.py) sont corrigés par le plancher
# pypdf>=6.16.1 (BUG-093).
# pypdf>=6.16.1 (BUG-093), relevé à pypdf>=6.21.2 par #224 (11 CVE
# sur la 6.19.0 de la toolcache, corrigées au plus tard en 6.21.2).
# #224 ajoute les planchers brotli>=1.2.1 (PYSEC-2026-4322,
# GHSA-9v8h-qqgm-4m35), filelock>=4.2.1 (CVE-2026-112940,
# CVE-2026-112913, CVE-2026-112907) et opentelemetry-api>=1.45.1
# (CVE-2026-114603) : paquets transitifs ou préinstallés dans la
# toolcache, audités mais jamais alignés sans plancher explicite.
# CVE-2026-97687 / CVE-2026-97688 / CVE-2026-97689 (urllib3 2.7.0)
# corrigés par le plancher urllib3>=2.8.0.
# CVE-2026-104874 (multidict 6.7.x) corrigé par le plancher
@@ -203,7 +209,7 @@ jobs:
# ── Docker build ──────────────────────────────────────────────────
build:
needs: test
runs-on: ubuntu-latest
runs-on: ci-deepin
steps:
- uses: actions/checkout@v4
@@ -246,7 +252,7 @@ jobs:
# ── E2E Tests (Playwright) ─────────────────────────────────────────
e2e:
needs: build
runs-on: ubuntu-latest
runs-on: ci-deepin
steps:
- uses: actions/checkout@v4
@@ -266,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 \
@@ -282,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 ».
@@ -300,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()
Regular → Executable
View File
Regular → Executable
View File
+311 -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.76.1**.
> [Unreleased](#unreleased). La dernière version livrée est **2.86.2**.
---
@@ -14,6 +14,316 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.86.2] — 2026-11-08
- **BUG-123 — Aller-retour SSO refermé aussi quand la session ObsiGate existe déjà** (chantier « entrée SSO de bout en bout », écart constaté par Bruno en test réel le 2026-11-08) : `GET /sso/authorize?return_to=…` redirige vers `/?return_to=…`, mais le retour n'était honoré que par `AuthManager.saveToken()` (login frais, MFA, WebAuthn). Pour un utilisateur déjà connecté chez ObsiGate, `initAuth()` restaurait la session — jeton encore valide ou refresh silencieux — et affichait l'accueil sans jamais suivre le `return_to` : l'utilisateur restait chez ObsiGate, et l'application sœur ne recevait jamais son pont (aucune session locale). Correctif (`frontend/js/auth.js`) : `_honorReturnTo()` signale désormais la redirection (valeur de retour), et les deux chemins de restauration d'`initAuth()` l'invoquent avant d'afficher l'application. Les refresh silencieux **en cours de session** ne sont pas touchés : ils ne doivent jamais rediriger. Même liste blanche qu'avant (https + `*.dracodev.net`, jamais l'hôte courant). Garde : `tests/test_draco_barre.py` (`test_barre_v070_setuser_et_retour_sso_restauration`) ; chaîne complète prouvée en navigateur réel dans une pile locale aux noms publics (dossier `preuve-entree-sso`). Fiche : `docs/features/entree-sso-bug-123-124.md`.
- **BUG-124 — Le nom du compte rejoint la barre commune dès le login, sans rechargement** (même chantier) : après un login dans la SPA, le menu compte de la barre affichait l'en-tête générique « Compte » et l'avatar neutre « ✦ » jusqu'au rechargement forcé — `renderUserSection()` poussait le rôle (`setAdmin`) mais jamais le nom, et le seul poseur d'identité était le fetch d'amorçage d'`index.html`, limité aux sessions déjà présentes au chargement. Barre commune re-vendangée **verbatim** en **v0.7.0** depuis dracodev-commons (PR commons #15) : nouvelle API `DRACO_BAR.setUser(user)` qui resynchronise l'avatar (initiale, infobulle) et l'en-tête du menu compte immédiatement. Branchement hôte : `renderUserSection()` appelle `setUser(name)` pour toute session établie (et `setUser(null)` sans identité), le fetch d'amorçage d'`index.html` également. Garde : `tests/test_draco_barre.py` (même test).
---
## [2.86.1] — 2026-11-06
- **Barre commune v0.6.0 — entrée « Administration » du menu compte** (fusion #142, demande de Bruno) : barre re-vendangée **verbatim** depuis dracodev-commons (PR commons #14) — `frontend/draco-barre.js` et `frontend/draco-barre.css`. Le menu compte gagne un cinquième lien Hub, « Administration » → `<hub>/admin` (le Hub redirige vers `/admin/utilisateurs`), après le séparateur et avant « Déconnexion », à **visibilité conditionnelle** : l'entrée n'apparaît que si l'hôte affirme que le compte courant est administrateur de suite (`DRACO_BAR.setAdmin`). Branchement hôte : `frontend/index.html` affirme le statut depuis la réponse de `/api/auth/me` déjà chargée pour l'avatar — `setAdmin(u.role === "admin")`. Le rôle global du compte chez ObsiGate est la définition même de l'administrateur de suite (c'est lui que la garde `/admin/*` du Hub vérifie, via ce même endpoint) : ObsiGate le connaît nativement. Tout autre rôle, ou échec de l'appel : aucune affirmation, l'entrée reste masquée. Garde : `tests/test_draco_barre.py` (`test_barre_v060_entree_administration`).
---
## [2.86.0] — 2026-11-06
- **BUG-122 — Déconnexion globale navigable : `GET /sso/logout`** (fusion #141) : le bouton « Déconnexion » du menu compte de la barre commune ne faisait rien dans les applications sœurs — la barre relaie l'action à l'hôte, et la déconnexion globale d'ObsiGate (ACC-3.1, #216) n'était appelable qu'en same-origin depuis l'interface d'ObsiGate (`POST /api/auth/logout`). Nouvelle route `GET /sso/logout?return_to=`, symétrique de `/sso/authorize` : elle exécute la déconnexion globale — logique extraite dans `apply_global_logout()` (`backend/auth/router.py`), désormais partagée par les deux entrées (jetons natifs refresh/access révoqués, `jti` du cookie `draco_session` révoqué, cookie effacé sur le domaine parent) — puis redirige vers le `return_to` validé par la liste blanche existante (`validate_return_to` : https + `*.dracodev.net`, repli « / »). Idempotente sans session. Les applications sœurs branchent leur action `deconnexion` sur cette route (leur session locale est détruite d'abord chez celles qui en ont une) — et la barre d'ObsiGate elle-même aussi (`frontend/index.html`, retour à sa racine). Tests : `tests/test_draco_session.py` (+3 : révocation et redirection, liste blanche, idempotence), `tests/test_draco_barre.py` (+1 : branchement de la barre d'ObsiGate). Fiche : `docs/features/deconnexion-globale-bug-122.md`.
---
## [2.85.1] — 2026-11-06
- **HUB-19 — Propagation des rôles du Hub à ObsiGate (suite du pilote ShaarIt, fusion Hub v2 §2.2, décision D3(a), #228)** : ObsiGate, à la fois émettrice et consommatrice du SSO, consomme à son tour le registre des rôles du Hub. Nouveau client `backend/hub_roles.py` : à l'ouverture de session draco (validation locale `draco_session.user_for_draco`), consultation de `GET {hub}/api/roles/obsigate` (en-tête Cookie complet relayé, `Accept: application/json`, délai 2 s), cache mémoire 60 s par `sub` sur le patron du pilote (horodatage posé avant l'appel : un échec conserve l'ancien cache, pas de rafale) ; URL du Hub résolue comme la barre commune (`draco_config.app_url("hub")` : `DRACO_HUB_URL` explicite, dérivation par l'hôte sœur, défaut officiel — jamais d'URL dev figée). **Effets, sessions établies par draco seulement** : niveau connu `aucun` → session draco refusée, `require_auth` répond 403 avec un message explicite en français (l'authentification native d'ObsiGate — l'émettrice, son accès propre est le secours — n'est jamais touchée) ; routes d'administration `/api/auth/admin/*` : nouvelle garde `require_admin_hub` — canal draco + rôle connu ⇒ niveau `administrateur` du registre exigé, sessions natives et rôle inconnu (Hub injoignable ou en erreur) ⇒ règles actuelles inchangées, aucun verrouillage nouveau, et le registre n'élève aucun privilège local. Validation locale du jeton, émission, révocations et renouvellement inchangés ; aucun rôle dans le jeton. Aucun impact frontend (pas d'i18n). Fiche : `docs/features/hub-roles-obsigate-228.md`. Tests : `tests/test_hub_roles.py` (client : parsing/cache/repli/résolution d'URL ; intégration : refus draco, garde admin). **Hypothèse de version** : branche depuis le main 2.84.1, cible 2.85.1 — rebasée sur la 2.85.0 (PR « API sessions HUB-22 » fusionnée d'abord) avant fusion.
## [2.85.0] — 2026-11-06
- **HUB-22 — API machine des sessions draco (vague C du plan Hub v2.1, #227)** : trois nouvelles routes sous la garde machine existante (`DRACO_ADMIN_MACHINE_KEY`, en-têtes `X-Draco-Machine-Key` / `X-Draco-Actor` ; 401 sans clé valide, 404 si l'API n'est pas configurée ou le compte inconnu) — `GET /api/admin/machine/comptes/{username}/sessions` liste les sessions actives du compte (`jti`, `exp`, `expire_le` en ISO 8601 UTC, `revoquee`), `POST …/sessions/{jti}/revoquer` révoque une session (404 si le `jti` n'est pas dans l'ensemble actif de ce compte — jamais de révocation cross-utilisateur) et `POST …/sessions/revoquer-toutes` révoque toutes les sessions (déconnexion globale, **sans désactiver le compte**). Backend : `draco_session.issued_sessions_for_user()` (sessions actives triées par expiration, purge des expirés au passage) et `revoke_one_for_user()` (appartenance au `sub` vérifiée avant révocation), sur le registre des émissions de HUB-13 et la même clé d'identité (`id`, à défaut `username`). Écritures journalisées dans `data/audit.log` (`session_revoquee`, `sessions_revoquees`). Fiche : `docs/features/hub-sessions-machine-227.md`. Tests : `tests/test_comptes_machine.py` (`TestSessions`), `tests/test_draco_session.py`.
---
## [2.84.1] — 2026-11-06
- **HUB-18 — Thème commun (fusion #132)** : barre commune re-vendangée **verbatim** en **v0.5.0** depuis dracodev-commons (branche `hub18-theme-commun`, PR commons #12) — `frontend/draco-barre.js` seul change côté barre (CSS et fragment inchangés). La barre apprend la préférence de thème du profil du Hub (`GET <hub>/api/profil/theme`, cookie draco ; `<hub>` = URL résolue de la barre, jamais figée) et l'applique à l'ouverture : cache `localStorage` `draco-theme-hub` (TTL 12 h) appliqué synchrone, puis fetch asynchrone non bloquant ; `sombre`/`clair` posent `data-theme`, `systeme` le retire (`prefers-color-scheme` décide). **Repli intégral** si le Hub ne répond pas, répond non-OK ou `theme: null` : le comportement actuel (choix local, puis système) reste seul maître. Initialisation propre à ObsiGate dans `frontend/app.js` : `initTheme()` écoute l'événement `draco:theme` de la barre et route le mode effectif par `applyTheme()` — icône et libellé du bouton de thème et feuilles highlight.js restent synchronisés, le mode appliqué devient le choix local mémorisé (`obsigate-theme`), et le bouton de bascule reste fonctionnel pour la session. Garde ajoutée à `tests/test_draco_barre.py`.
---
## [2.84.0] — 2026-11-06
- **HUB-13 — API d'administration des comptes, machine-à-machine (fusion #127, #226)** : nouvelles routes `/api/admin/machine/comptes` (liste, profil étendu, création, modification, désactivation/réactivation, suppression, réinitialisation du mot de passe et du MFA), protégées par la clé machine dédiée `DRACO_ADMIN_MACHINE_KEY` (en-tête `X-Draco-Machine-Key`, distincte de `DRACO_SSO_MACHINE_KEY` ; aucune session utilisateur ne les ouvre ; API inerte si la clé n'est pas configurée). Le profil étendu expose nom d'utilisateur, nom affiché, langue, avatar (valeur + présence), rôle global, état actif, MFA activé oui/non — jamais le secret TOTP ni le haché du mot de passe. La désactivation arrête l'émission de nouveaux jetons et révoque les `jti` draco actifs : `draco_session` tient désormais un registre des émissions (`data/draco_issued.json`) et `revoke_all_for_user()` révoque via la liste existante ; le repère `password_changed_at` (BUG-028) est aussi avancé. Nouveau champ utilisateur `must_change_password` : posé à la création machine et à la réinitialisation du mot de passe, signalé par la connexion et `GET /api/auth/me`, effacé au changement effectif. Chaque action est journalisée dans `data/audit.log` (acteur = identité de service `X-Draco-Actor`, action, compte cible, horodatage). Fiche : `docs/features/hub-api-admin-comptes-226.md`. Tests : `tests/test_comptes_machine.py`.
---
## [2.83.5] — 2026-11-05
- **Barre commune v0.4.1 — correctif des icônes invisibles du menu compte (fusion #125)** : seul `frontend/draco-barre.js` change, re-vendangé **verbatim** depuis dracodev-commons (branche `menu-compte-icones-svg-ns-v0-4-1`, PR commons #11) ; le CSS est inchangé. En v0.4.0, `menuIcone()` créait le `<svg>` des icônes et ses primitives via `document.createElement()` — espace de noms **HTML**, jamais dessinés par le navigateur : les libellés du menu compte étaient décalés d'un cran d'icône, sans aucun glyphe. La v0.4.1 crée tout via `document.createElementNS()` (espace de noms SVG). Glyphes, CSS et comportements inchangés. Garde ajoutée à `tests/test_draco_barre.py`.
---
## [2.83.4] — 2026-11-05
- **Barre commune v0.4.0 — icônes monoline du menu compte (HUB-11, fusion #125)** : `frontend/draco-barre.js` et `frontend/draco-barre.css` re-vendangés **verbatim** depuis dracodev-commons v0.4.0 (branche `menu-compte-icones-v0-4-0`, PR commons #10). Les items du menu compte — **Télécharger**, **À Propos**, **Aides**, **Profil**, **Déconnexion** — reçoivent des **icônes SVG monoline** (contour, trait fin, `currentColor`, ~18 px) dans le style des icônes du rail de la console Spark ; aucun emoji. L'en-tête d'identité reste sans icône ; les liens Hub et le relais « Déconnexion » sont inchangés. Garde ajoutée à `tests/test_draco_barre.py`.
---
## [2.83.3] — 2026-11-04
- **Barre commune v0.3.0 — liens Hub du menu compte (HUB-1, fusion #115)** : `frontend/draco-barre.js` et `frontend/draco-barre.css` re-vendangés **verbatim** depuis dracodev-commons v0.3.0 (branche `hub-menu-compte-v0-3-0`, PR commons #9). Le menu compte gagne les liens **Télécharger**, **À Propos**, **Aides** et **Profil** (devenu un lien, plus un relais) vers le Hub. Branchement hôte : `backend/draco_config.py` gagne la clé `hub` (défaut officiel `https://hub.dracodev.net`, variable `DRACO_HUB_URL`, dérivation `-dev` → `hub-dev` depuis l'hôte de l'agent), servie dans `window.DRACO_URLS` ; `frontend/index.html` la passe en `urls.hub` de `DRACO_BAR_CONFIG`. « Déconnexion » reste relayée, inchangée. Aucun nouvel onglet : le Hub n'apparaît que dans le menu compte.
---
## [2.83.2] — 2026-11-04
- **Recherche fédérée — ShaarIt, 5e source de « Rechercher partout »** (demande de Bruno, 2026-11-04 ; contrat dracodev-commons v0.2.1, `contrats/recherche-federee.md`) : `frontend/js/recherche.js` **re-vendangé verbatim** depuis la source canonique de commons (`ui/recherche/`) — ShaarIt devient une source de la page `/recherche` comme les autres (bases prod/dev, onglet, normaliseur : lien profond canonique `{base}shaare/<shorturl>`, contexte « Bookmark · privé · tags », repli recherche native `/?q=`). Le fichier reste strictement identique chez les cinq hôtes.
- **Page `/recherche`** : onglet et section « ShaarIt — bookmarks », `data-base-shaarit` rendu par le jeton `{{URL_SHAARIT}}` (déjà résolu par la route depuis la 2.83.1) ; la CSP `connect-src` de la page ajoute l'origine ShaarIt résolue (`backend/main.py`).
- **CORS de recherche** (`backend/recherche_cors.py`) : origines officielles ShaarIt prod et dev ajoutées aux listes explicites, `shaarit` ajoutée aux clés sœurs résolues — `GET /api/search` d'ObsiGate devient lisible depuis les pages de ShaarIt, et réciproquement.
- Tests : `tests/frontend/recherche.test.mjs` porté à 5 sections (normaliseur ShaarIt inclus) ; `tests/test_recherche_cors.py` — allowlist attendue à 12 origines.
---
## [2.83.1] — 2026-11-03
- **Barre commune (dracodev-commons v0.2.0 vendangée, @70c837a)** : nouvel onglet **ShaarIt** (bookmarks, réécriture Rust de Shaarli) après Kanbante et avant l'onglet Console Spark — `frontend/draco-barre.css` et `frontend/draco-barre.js` remplacés **verbatim** par la v0.2.0 (hachages vérifiés), logo `frontend/img/logos/shaarit.svg` ajouté tel quel ; le fragment porté dans `frontend/index.html` et `frontend/recherche.html` (jeton `{{URL_SHAARIT}}` remplacé par la route `/recherche`). L'URL ShaarIt suit le **même mécanisme** que les autres apps : `backend/draco_config.py` gagne la clé `shaarit` (défaut officiel `https://shaarit.dracodev.net`, variable `DRACO_SHAARIT_URL`, dérivation `-dev` → `shaarit-dev` depuis l'hôte de l'agent), servie dans `window.DRACO_URLS` et réécrite sur l'onglet comme les autres.
- **⚠️ Changement de comportement visible — garde `data-theme` (CSS v0.2.0)** : les blocs `prefers-color-scheme` du CSS vendangé sont désormais préfixés `html:not([data-theme])`. La copie précédente n'avait **pas** cette garde. ObsiGate pose toujours `data-theme` sur `<html>`, donc sa barre ne change pas en pratique ; la garde ne s'exercerait que sur une page hôte sans `data-theme` (elle ne forcerait plus le sombre système contre un choix clair). Signalé pour arbitrage à la fusion, comme chez Flowdeck où le piège était actif.
---
## [2.83.0] — 2026-11-03
- **Vaults DIR : exclusions par vault et lecture seule déclarable** (prépare le vault « Spark ») : trois nouvelles variables par vault DIR — `DIR_N_READONLY=true` (les mutations sont refusées en 403 « read_only » et l'arborescence n'affiche plus les actions d'écriture pour ce vault), `DIR_N_IGNORED_DIRS=.home,.venv,…` (noms de dossiers cachés à toute profondeur, en plus du global `IGNORED_DIRS`) et `DIR_N_IGNORED_PATHS=Executeurs/*/data,…` (motifs fnmatch sur le chemin relatif ; un dossier qui matche cache tout son sous-arbre). Les exclusions s'appliquent désormais **partout** — navigation (`/api/browse`), lecture (`/api/file`, `raw`, téléchargement : 404), liste des chemins et indexation — et plus seulement à l'indexeur. `/api/vaults` expose `readonly` pour chaque vault. Sans ces variables, comportement inchangé. Fiche : `docs/features/spark-vault-dir.md`. Tests : `tests/test_spark_vault_dir.py`.
---
## [2.82.8] — 2026-10-30
- **Panneau détail d'un secret : Afficher/Masquer + Copier la valeur** (PR #57) : la valeur reste masquée (••••••) par défaut ; Afficher la révèle en place, Masquer l'efface (effacée aussi dès que le détail change). « Copier la valeur » copie au presse-papiers sans afficher. Le bouton « Copier » existant ne copiait que la référence.
- **Nouvel endpoint `POST /api/coffres/{slug}/secrets/{nom}/reveler`** : session + gestion du coffre exigées (propriétaire, ou admin sur coffre d'instance) ; un lecteur par octroi utilisateur reçoit 403. Chaque révélation est auditée (`revelation_manuelle`) et met à jour la dernière utilisation. Ce n'est pas une distribution : aucun octroi créé, aucun consommateur ne reçoit la valeur.
- **Suppression de la « Copie manuelle à usage unique »** par consommateur temporaire : elle échouait systématiquement (aucun octroi pour le consommateur temporaire, donc 403 octroi_absent) ; « Copier la valeur » la remplace.
- **Tests** : `tests/test_coffre_secrets.py` 44/44 (5 nouveaux TestRevelation).
## [2.82.7] — 2026-10-29
- **Barre commune (dracodev-commons v0.1.11 vendangée)** : la marque de la barre n'est plus le logo rouge — c'est l'étoile Spark monochrome (`frontend/img/brand-spark.png`, servi en `/static/img/brand-spark.png`, 26 px), blanche sur barre foncée et noire sur barre claire : ObsiGate pose déjà `data-theme` sur `<html>` (sombre par défaut, mode persisté), donc la règle vendangée `[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }` s'applique directement et aucune surcharge propre n'est nécessaire. L'onglet Console Spark (et son entrée de menu, qui le clone) GARDE le logo rouge. Le bouton avatar Spark flottant bas-centre gagne un anneau rouge `#c00000` (bordure 3 px, `var(--agent)`), via le CSS vendangé.
- **Pages concernées** : la barre est rendue en ligne dans `frontend/index.html` et dans sa copie de `frontend/recherche.html` — les deux sont portées à la v0.1.11 ; `frontend/draco-barre.css` remplacé verbatim par la v0.1.11, `frontend/draco-barre.js` inchangé (identique à commons, lui-même inchangé en v0.1.11). Le reste de l'intégration est inchangé. Précision : sur `/recherche`, copie statique, le bouton flottant y est visuel seulement, comme au lot précédent — l'ouverture du panneau vit dans la SPA.
## [2.82.6] — 2026-10-29
- **Barre commune (dracodev-commons v0.1.10 vendangée)** : le rose Spark `#c2437e` disparaît de la barre — `--agent` passe au rouge Spark `#c00000` (échantillonné dans le logo Spark), l'onglet Console Spark passe à `--app #c00000`, et la marque ✦ devient le logo Spark (26 px). Le bouton « Spark » rose de la barre est supprimé, remplacé par le bouton avatar Spark flottant bas-centre (cercle personnage, `frontend/img/avatar-spark.png`, servi en `/static/img/avatar-spark.png`, `data-act="spark"` → panneau Spark de l'hôte, comme l'ancien bouton). Le bouton Alertes n'affiche plus que la cloche (pastille conservée), donc une barre plus étroite.
- **Pages concernées** : la barre est rendue en ligne dans `frontend/index.html` et dans sa copie de `frontend/recherche.html` — les deux sont portées à la v0.1.10 ; `frontend/draco-barre.css` remplacé verbatim par la v0.1.10, `frontend/draco-barre.js` inchangé (identique à commons v0.1.9, lui-même inchangé en v0.1.10). Le reste de l'intégration (URL, `DRACO_URLS`, actions, compteurs) est inchangé. Précision : sur `/recherche`, copie statique, le panneau Spark n'est pas rejoué (comme avant) — le bouton flottant y est visuel, l'ouverture du panneau vit dans la SPA.
## [2.82.5] — 2026-10-29
- **Barre commune (dracodev-commons v0.1.9 vendangée)** : les 5 tuiles des onglets — et donc du menu de la marque, qui les clone — sont désormais les logos fournis, tels quels, jamais redessinés : ObsiGate (arbre), Flowdeck (cartes), TikTak (logo officiel SVG), Kanbante (cycle vert), Console Spark (étoile rouge validée). Fichiers dans `frontend/img/logos/` (copie de `ui/barre-apps/logos/` de commons, PNG 180 px), servis en `/static/img/logos/` ; l'entrée « Console Spark » du menu clone l'icône de l'onglet agent (`draco-barre.js`) ; onglet agent passé à `--app #d21f2c`. Les glyphes texte ▤/▦/✓/▥/✦ disparaissent des onglets (marque ✦, bouton Spark et rappel d'en-tête inchangés).
- **Pages concernées** : la barre est rendue en ligne dans `frontend/index.html` et dans sa copie de `frontend/recherche.html` — les deux sont portées à la v0.1.9, `draco-barre.js` et `draco-barre.css` remplacés verbatim (les fichiers précédents étaient identiques à l'octet près à ceux de commons avant la v0.1.9, aucune adaptation locale perdue). Le reste de l'intégration (URL, `DRACO_URLS`, actions, compteurs) est inchangé.
## [2.82.4] — 2026-10-28
- **Recherche (PR #52)** : texte parasite du mécanisme de thème retiré du haut de la page `/recherche` (lignes 28-43 de `frontend/recherche.html`, hors commentaire).
- **Démarrage (PR #53)** : écran de chargement plein écran `#boot-splash` retiré au démarrage d'ObsiGate (CSS, div et script de pilotage), sans écran de remplacement.
## [2.82.3] — 2026-10-28
- **Recherche (PR #49)** : la page `/recherche` applique désormais le thème ObsiGate.
- **CORS recherche (PR #51)** : la recherche ObsiGate autorise désormais la console Spark (agent prod+dev) en inter-origines.
## [2.82.2] — 2026-10-28
- **Recherche (#68)** : l'onglet Console Spark de `/recherche` appelle désormais `GET /api/v1/recherche` (au lieu de `/api/search`) et reconstruit les liens profonds console (`?section=…&objet=…`, `disc=`, `activite=`) — les résultats console réels s'affichent enfin dans la page hôte ; barre de `/recherche` re-vendangée depuis `index.html` (5e onglet Console Spark) et `connect-src` CSP étendu à l'origine agent.
## [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
---
## [2.80.2] — 2026-10-27
### Corrigé
- **CI lint (mypy)** — 3 erreurs dans le Coffre Secrets : annotation explicite `type[Any] | None` pour `AESGCM` dans `coffre_crypto.py` (le repli sans `cryptography` reste `None`, et `_require_aesgcm()` retourne désormais la classe pour un narrowing propre) ; annotation `dict[str, Any]` du coffre dans `create_coffre` (`coffre_store.py`), qui inférait ses valeurs en `str | None` et refusait le dict de `wrap_dek` ainsi que `coffre["id"]` en argument. Aucun `type: ignore` aveugle ajouté, aucun changement de comportement.
- **Bouton Nouveau secret inoperant (console NotFoundError)** — `valeurField` appelait `wrap.insertBefore(input, eyeBtn)` alors que le bouton oeil n'avait jamais ete rattache a `wrap` : l'oeil est desormais ajoute a `wrap` avant le premier `build`, la modale de creation (et celle de rotation) s'ouvre.
- **CI security (pip-audit)** — plancher `msgpack>=1.2.3` : la version transitive 1.2.1 etait signalee pour CVE-2026-114613 (correctif 1.2.3).
---
## [2.80.1] — 2026-10-27
### Ajouté
- **Coffre Secrets — Phase 1** (contenu de la release 2.80.0, dont l'entrée changelog manquait) : chiffrement par enveloppe AES-256-GCM, API `/api/coffres`, nœud virtuel Secrets sous le Home de l'utilisateur.
### Corrigé
- **Étiquette de version 2.80.0** — le tag v2.80.0 pointait sur un contenu dont le fichier `VERSION` valait encore 2.79.0 (un merge ne déclenche pas le bump automatique) ; `VERSION` et tous les fichiers dérivés passent à 2.80.1, pour que la version affichée corresponde au tag de la prochaine release.
- **CI lint (ruff)** — 60 erreurs corrigées dans `backend/` : alias `datetime.UTC` (Python 3.11+), `TimeoutError` natif, tri d'imports et import inutilisé dans `coffre_store.py`, `.encode()` sans argument redondant, `fromisoformat` gérant « Z » ; aucune règle ruff masquée, aucun changement de comportement.
---
## [2.79.0] — 2026-10-26
### Ajouté
- **Panneau Spark : version du service d'agents visible en mode
sidebar (#225)** — le pied du panneau Spark
(`frontend/draco-panneau.js`, asset partagé des 4 applications)
affiche la ligne discrète « Service d'agents vX », comme la
console pleine page. La version est lue en direct dans la santé
du service (`GET /healthz` de l'origine injectée par l'hôte), à
l'ouverture du panneau, avec cache de session ; jamais une
valeur codée en dur — service injoignable, rien d'affiché.
Demande de Bruno (2026-10-26), carte fusion #64 ; même
livraison : TikTak 0.61.3, Flowdeck 7.83.1, Kanbante 0.37.1.
---
## [2.78.2] — 2026-10-26
### Corrigé
- **Menu inter-apps : les URL sœurs suivaient l'environnement de
l'agent (BUG-120, suite de #223)** — sur la dev
(`og-dev.dracodev.net`), le `window.DRACO_URLS` servi donnait
l'agent en `-dev` mais les applications sœurs (ObsiGate, Flowdeck,
TikTak, Kanbante) en noms **prod** : seule l'URL de l'agent avait
une dérivation de repli, et le `.env` dev ne déclare pas les quatre
`DRACO_<APP>_URL`. Résolution d'une URL sœur dans
`backend/draco_config.py`, inchangée dans son contrat : (1) la
variable explicite prime ; (2) à défaut, **dérivation
d'environnement** depuis l'hôte de l'agent tel qu'ObsiGate le
connaît déjà (`agent_url()`, qui lit `AGENT_EVENEMENTS_URL`) — si
cet hôte est de la forme `agent<SUFFIXE>.dracodev.net`, l'URL sœur
devient `https://<slug><SUFFIXE>.dracodev.net` (`og`, `flowdeck`,
`tiktak`, `kanbante`) ; hôte hors motif (autre domaine, LAN, IP,
port) → **aucune dérivation**, jamais de lien deviné ; (3) défaut
officiel. L'URL Gitea ne change pas. Aucune variable supplémentaire
à déclarer en dev. Tests `tests/test_draco_config.py` étendus
(dérivation `-dev`, priorité de l'explicite, hôtes hors motif,
non-régression sans env). Redéploiement Docker requis.
---
## [2.78.1] — 2026-10-25
### Corrigé
- **Sécurité des dépendances — `pip-audit` rouge sur 2.78.0 (#224)** —
le job `security` du run CI 2303 signalait 17 vulnérabilités dans
4 paquets hérités de l'environnement du runner (versions
préinstallées dans la toolcache, jamais alignées car « already
satisfied » — même piège que BUG-093). Planchers explicites dans
`backend/requirements.txt` : `pypdf>=6.21.2` (11 CVE sur la 6.19.0,
dont CVE-2026-113786 et CVE-2026-115572/115585/115580),
`brotli>=1.2.1` (PYSEC-2026-4322, GHSA-9v8h-qqgm-4m35),
`filelock>=4.2.1` (CVE-2026-112940, CVE-2026-112913,
CVE-2026-112907) et `opentelemetry-api>=1.45.1`
(CVE-2026-114603). Garde-fou `TestDependencySecurityFloors`
étendu ; aucun code applicatif touché, aucun nouvel ignore
`pip-audit`. Redéploiement Docker requis.
---
## [2.78.0] — 2026-10-25
### Modifié
- **Dev/prod sans noms publics en dur (#223, Fusion)** — toutes les URL
publiques de l'écosystème dracodev viennent désormais de la
configuration (`backend/draco_config.py` : `DRACO_AGENT_URL`,
`DRACO_OBSIGATE_URL`, `DRACO_FLOWDECK_URL`, `DRACO_TIKTAK_URL`,
`DRACO_KANBANTE_URL`, `DRACO_GITEA_URL` ; défauts = noms officiels,
l'environnement dev déclare ses noms en `-dev` dans son seul `.env`).
La CSP (`connect-src`/`frame-src`) dérive l'origine de l'agent de la
config (à défaut, de l'origine d'`AGENT_EVENEMENTS_URL`) ; le backend
injecte `window.DRACO_URLS` dans `index.html` pour le panneau Spark,
la barre d'apps et les liens inter-apps ; les références croisées
reconnaissent les hôtes officiels et configurés et leur URL
d'affichage est reconstruite depuis la config à la lecture (aucune
donnée stockée migrée). WebAuthn confirmé dérivé de la requête /
`OBSIGATE_WEBAUTHN_*` (aucun changement). Redéploiement Docker
requis ; en dev, déclarer les noms en `-dev` dans le `.env` du poste.
---
## [2.77.1] — 2026-10-24
### Corrigé
- **Panneau Spark : « mode console » bloqué par la CSP (BUG-119, Fusion)** —
la surimpression du panneau Spark V3 (#222) charge la console du service
d'agents (`https://agent.dracodev.net`) dans une iframe, mais la CSP servie
limitait `frame-src` à `'self' blob:` : le navigateur refusait le
chargement (« This content is blocked »). `frame-src` autorise désormais
aussi `https://agent.dracodev.net` — déjà présente dans `connect-src`
pour le panneau texte ; aucune autre directive n'est modifiée. Test de
non-régression dans `tests/test_security_headers.py`. Redéploiement
Docker requis pour l'activer côté ObsiGate.
---
## [2.77.0] — 2026-10-23
### Modifié
- **Panneau Spark V3 : la console en surimpression pleine page
(#222, Fusion)** — le bouton « Console ⤢ » du panneau Spark
(asset vendangé, octet-identique dans les 4 apps) n'ouvre plus
la console web du service d'agents en nouvel onglet (#221) :
elle s'ouvre **dans la même page**, en surimpression née à la
largeur du panneau et étendue à toute la fenêtre par
transition, avec le lien profond conservé (`?disc=<id>` et
`?app=obsigate`). Sa barre offre les deux sorties : « Mode
sidebar » (retour à l'agent en barre latérale) et « Fermer
l'agent ». Les styles sont injectés par le panneau lui-même ;
côté service (0.20.0), la console autorise l'encadrement par
les 4 applications. Même livraison dans TikTak 0.60.0,
Flowdeck 7.81.0 et Kanbante 0.29.0. Redéploiement Docker
requis pour l'activer côté ObsiGate.
---
## [2.76.1] — 2026-10-22
### Ajouté
+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.76.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.86.2-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.76.1).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.86.2).
---
*Projet : ObsiGate | Version : 2.76.1 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.86.2 | 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.76.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.86.2-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.76.1).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.86.2).
---
*Project: ObsiGate | Version: 2.76.1 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.86.2 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.76.1
2.86.2
+2 -2
View File
@@ -10,7 +10,7 @@ import logging
import os
import shutil
import time
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -244,7 +244,7 @@ def _build_stats_payload() -> dict:
"disk_used_gb": disk_used_gb,
"disk_total_gb": disk_total_gb,
"uptime_seconds": int(_server_uptime_seconds()),
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
}
+12 -1
View File
@@ -55,6 +55,9 @@ import httpx
logger = logging.getLogger("obsigate.agent_journal")
SOURCE_APP = "obsigate"
# Défaut officiel, conservé pour référence : depuis #223, ``_url()``
# passe par la config centralisée (``backend.draco_config``) — jamais
# un nom d'environnement particulier.
URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements"
DELAI_S = 2.0
@@ -72,7 +75,15 @@ def _cle() -> str:
def _url() -> str:
return os.environ.get("AGENT_EVENEMENTS_URL", "").strip() or URL_DEFAUT
# #223 : AGENT_EVENEMENTS_URL d'abord ; à défaut, l'URL du service
# configurée (DRACO_AGENT_URL) complétée du chemin d'événements ;
# à défaut, le défaut officiel.
explicite = os.environ.get("AGENT_EVENEMENTS_URL", "").strip()
if explicite:
return explicite
from backend.draco_config import agent_url
return f"{agent_url()}/api/v1/evenements"
def actif() -> bool:
+70 -6
View File
@@ -10,7 +10,7 @@ Format: JSON lines (one JSON object per line) for easy parsing.
import json
import logging
import os
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
logger = logging.getLogger("obsigate.audit")
@@ -54,7 +54,7 @@ def log_file_save(
):
"""Log a file save (PUT) operation."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "file_save",
"username": username,
"ip": ip or "unknown",
@@ -72,7 +72,7 @@ def log_file_delete(
):
"""Log a file delete operation."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "file_delete",
"username": username,
"ip": ip or "unknown",
@@ -88,7 +88,7 @@ def log_config_change(
):
"""Log a configuration change."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "config_change",
"username": username,
"ip": ip or "unknown",
@@ -99,7 +99,7 @@ def log_config_change(
def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | None = None):
"""Log a vault addition."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "vault_add",
"username": username,
"ip": ip or "unknown",
@@ -111,7 +111,7 @@ def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | Non
def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
"""Log a vault removal."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "vault_remove",
"username": username,
"ip": ip or "unknown",
@@ -119,6 +119,70 @@ def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
})
def log_coffre_event(
username: str,
action: str,
coffre: str | None = None,
secret_ref: str | None = None,
consommateur: str | None = None,
resultat: str | None = None,
contexte_id: str | None = None,
ip: str | None = None,
):
"""Journal d'un événement du coffre Secrets (Phase 1b).
Règle absolue : aucun champ ne contient jamais une valeur de secret,
un texte chiffré, un nonce ou un jeton. Seules des références
(coffre:<slug>/<nom>), des identifiants et des codes de résultat.
"""
entry = {
"timestamp": datetime.now(UTC).isoformat(),
"action": action,
"username": username,
"ip": ip or "unknown",
}
if coffre is not None:
entry["coffre"] = coffre
if secret_ref is not None:
entry["secret_ref"] = secret_ref
if consommateur is not None:
entry["consommateur"] = consommateur
if resultat is not None:
entry["resultat"] = resultat
if contexte_id is not None:
entry["contexte_id"] = contexte_id
_write_entry(entry)
def log_compte_admin(
acteur: str,
action: str,
cible: str,
ip: str | None = None,
details: dict | None = None,
):
"""Journal d'une action d'administration de compte (HUB-13, fusion #127).
``acteur`` est l'identité de service appelante (API machine des
comptes, en-tête ``X-Draco-Actor``), ``cible`` le compte visé.
Règle absolue : ``details`` ne contient jamais de mot de passe, de
secret TOTP ni de jeton — seulement des métadonnées (champs
modifiés, nombre de jti révoqués…). Le champ ``username`` reprend
l'acteur pour rester filtrable par l'écran d'audit existant.
"""
entry: dict = {
"timestamp": datetime.now(UTC).isoformat(),
"action": action,
"username": acteur,
"acteur": acteur,
"cible": cible,
"ip": ip or "unknown",
}
if details:
entry["details"] = details
_write_entry(entry)
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
"""Read the most recent audit log entries.
+43 -1
View File
@@ -141,9 +141,26 @@ def _draco_fallback(request: Request) -> dict | None:
return user
def require_auth(current_user=Depends(get_current_user)):
def require_auth(request: Request, current_user=Depends(get_current_user)):
"""Dependency: require a valid authenticated user."""
if not current_user:
# HUB-19 (#228) : la session draco a été refusée parce que le
# registre des rôles du Hub attribue le niveau « aucun » à ce
# compte pour ObsiGate — réponse explicite, distincte du 401
# générique. L'authentification native n'est pas concernée :
# ce refus ne naît que sur le chemin draco (user_for_draco).
if getattr(request.state, "role_hub_refuse", None) == "aucun":
raise HTTPException(
status_code=403,
detail=(
"Accès refusé : le registre des rôles du Hub attribue "
"le niveau « aucun » à votre compte pour ObsiGate, "
"l'accès par la session draco est donc refusé. "
"L'administrateur de la suite peut modifier ce niveau "
"dans la matrice des rôles du Hub. L'accès natif "
"d'ObsiGate (son propre identifiant) reste possible."
),
)
raise HTTPException(
status_code=401,
detail="Authentification requise",
@@ -159,6 +176,31 @@ def require_admin(current_user=Depends(require_auth)):
return current_user
def require_admin_hub(current_user=Depends(require_admin)):
"""Dependency: admin + garde du registre des rôles du Hub (HUB-19, #228).
Pour les routes d'administration des comptes (``/api/auth/admin/*``) :
quand la requête est authentifiée par le **canal draco** (``_draco_sub``
présent) et que le rôle du Hub est **connu** (``_hub_role`` non None),
le niveau ``administrateur`` du registre est exigé — le rôle local ne
suffit plus. Session native, ou rôle inconnu (Hub injoignable) :
règles actuelles inchangées.
"""
if current_user.get("_draco_sub"):
role_hub = current_user.get("_hub_role")
if role_hub is not None and role_hub != "administrateur":
raise HTTPException(
status_code=403,
detail=(
"Accès réservé au niveau « administrateur » d'ObsiGate "
"dans le registre des rôles du Hub (votre niveau actuel : "
f"« {role_hub} »). L'administrateur de la suite peut "
"l'accorder dans la matrice des rôles du Hub."
),
)
return current_user
def is_home_vault(vault_name: str) -> bool:
"""Un dossier personnel (#194) : vault « home-<user> »."""
return vault_name.startswith("home-")
+39 -14
View File
@@ -7,6 +7,7 @@ import binascii
import logging
import os
import re
from datetime import UTC
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
from pydantic import BaseModel, validator
@@ -39,7 +40,7 @@ from .mfa import (
verify_recovery_code,
verify_totp,
)
from .middleware import is_auth_enabled, require_admin, require_auth
from .middleware import is_auth_enabled, require_admin_hub, require_auth
from .password import hash_password, validate_password_strength, verify_password
from .user_store import (
create_user,
@@ -297,6 +298,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
"role": user["role"],
"vaults": user["vaults"],
"avatar": user.get("avatar"),
# HUB-13 : mot de passe temporaire — changement forcé signalé.
"must_change_password": bool(user.get("must_change_password")),
},
}
@@ -372,12 +375,15 @@ async def refresh_token_endpoint(request: Request, response: Response):
}
@router.post("/logout")
async def logout(
request: Request,
response: Response,
):
"""Logout: revoke refresh and access tokens, then delete cookies."""
def apply_global_logout(request: Request, response: Response) -> None:
"""Déconnexion globale (Fusion ACC-3.1, #216) appliquée à ``response``.
Révoque les jetons natifs (refresh + access, BUG-027), révoque le
``jti`` du cookie partagé ``draco_session`` et efface les cookies.
Partagée entre ``POST /api/auth/logout`` (réponse JSON) et
``GET /sso/logout`` (redirection, BUG-122) : les deux entrées
terminent exactement la même session, celle de la suite entière.
"""
refresh_tok = request.cookies.get("refresh_token")
if refresh_tok:
payload = decode_token(refresh_tok)
@@ -418,6 +424,15 @@ async def logout(
response.delete_cookie("refresh_token", path="/api/auth/refresh")
response.delete_cookie("access_token", path="/")
response.delete_cookie("access_token", path="/api") # just in case
@router.post("/logout")
async def logout(
request: Request,
response: Response,
):
"""Logout: revoke refresh and access tokens, then delete cookies."""
apply_global_logout(request, response)
return {"message": "Deconnecte avec succes"}
@@ -432,6 +447,8 @@ async def get_me(current_user=Depends(require_auth)):
"language": current_user.get("language", "fr"),
"last_login": current_user.get("last_login"),
"avatar": current_user.get("avatar"),
# HUB-13 : mot de passe temporaire — changement forcé signalé.
"must_change_password": bool(current_user.get("must_change_password")),
}
@@ -486,7 +503,11 @@ async def change_password(
assert user is not None, f"User {current_user['username']} not found"
if not verify_password(req.current_password, user["password_hash"]):
raise HTTPException(400, "Mot de passe actuel incorrect")
update_user(current_user["username"], {"password": req.new_password})
# HUB-13 : le changement effectif efface le marqueur de changement forcé.
update_user(current_user["username"], {
"password": req.new_password,
"must_change_password": False,
})
updated = get_user(current_user["username"])
result: dict = {"message": "Mot de passe mis à jour"}
if updated is not None:
@@ -696,7 +717,7 @@ async def mfa_webauthn_register(
Returns recovery codes when MFA is newly enabled (they were never issued).
"""
from datetime import datetime, timezone
from datetime import datetime
from .user_store import get_user, update_user
from .webauthn_mfa import complete_registration, resolve_relying_party
@@ -716,7 +737,7 @@ async def mfa_webauthn_register(
logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
raise HTTPException(400, "Validation du credential WebAuthn échouée")
record["registered_at"] = datetime.now(timezone.utc).isoformat()
record["registered_at"] = datetime.now(UTC).isoformat()
creds = list(user.get("webauthn_credentials", []))
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
creds.append(record)
@@ -948,9 +969,13 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
# ── Admin endpoints ───────────────────────────────────────────────────
# Garde `require_admin_hub` (HUB-19, #228) : `require_admin` + — pour
# une requête authentifiée par le canal draco dont le rôle du Hub est
# connu — exigence du niveau « administrateur » du registre des rôles.
# Sessions natives et rôle inconnu : règles actuelles inchangées.
@router.get("/admin/users")
async def list_users(admin=Depends(require_admin)):
async def list_users(admin=Depends(require_admin_hub)):
"""List all users (admin only). Password hashes are never included."""
return get_all_users()
@@ -958,7 +983,7 @@ async def list_users(admin=Depends(require_admin)):
@router.post("/admin/users")
async def create_user_endpoint(
req: CreateUserRequest,
admin=Depends(require_admin),
admin=Depends(require_admin_hub),
):
"""Create a new user (admin only)."""
try:
@@ -985,7 +1010,7 @@ async def create_user_endpoint(
async def update_user_endpoint(
username: str,
req: UpdateUserRequest,
admin=Depends(require_admin),
admin=Depends(require_admin_hub),
):
"""Update a user (admin only)."""
updates = req.dict(exclude_none=True)
@@ -998,7 +1023,7 @@ async def update_user_endpoint(
@router.delete("/admin/users/{username}")
async def delete_user_endpoint(
username: str,
admin=Depends(require_admin),
admin=Depends(require_admin_hub),
):
"""Delete a user (admin only). Cannot delete own account."""
if username == admin["username"]:
+17 -8
View File
@@ -8,7 +8,7 @@ import logging
import shutil
import threading
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path
from .password import hash_password
@@ -80,8 +80,16 @@ def create_user(
role: str = "user",
vaults: list[str] | None = None,
display_name: str | None = None,
must_change_password: bool = False,
) -> dict:
"""Create a new user. Raises ValueError if username already taken."""
"""Create a new user. Raises ValueError if username already taken.
``must_change_password`` (HUB-13) marque un mot de passe temporaire
(création/réinitialisation par l'administration machine) : le
changement est forcé à la première connexion. Les comptes créés
avant ce champ n'en portent pas — ils valent ``False`` partout où
le champ est lu via ``.get(..., False)``.
"""
with _users_lock:
data = _read()
if username in data["users"]:
@@ -95,10 +103,11 @@ def create_user(
"role": role,
"vaults": vaults or [],
"active": True,
"must_change_password": bool(must_change_password), # HUB-13
"language": "fr", # default UI language
"avatar": None, # profile picture data-URL (#113)
"created_at": datetime.now(timezone.utc).isoformat(),
"password_changed_at": datetime.now(timezone.utc).timestamp(),
"created_at": datetime.now(UTC).isoformat(),
"password_changed_at": datetime.now(UTC).timestamp(),
"last_login": None,
"failed_attempts": 0,
"locked_until": None,
@@ -126,7 +135,7 @@ def update_user(username: str, updates: dict) -> dict:
if "password" in safe_updates:
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
# BUG-028: invalidate every token issued before this change.
safe_updates["password_changed_at"] = datetime.now(timezone.utc).timestamp()
safe_updates["password_changed_at"] = datetime.now(UTC).timestamp()
data["users"][username].update(safe_updates)
_write(data)
@@ -148,7 +157,7 @@ def delete_user(username: str):
def record_login_success(username: str):
"""Record a successful login: update last_login, reset failed attempts."""
update_user(username, {
"last_login": datetime.now(timezone.utc).isoformat(),
"last_login": datetime.now(UTC).isoformat(),
"failed_attempts": 0,
"locked_until": None,
})
@@ -171,7 +180,7 @@ def record_login_failure(username: str) -> int:
# Lock after 5 failed attempts (15 minutes)
if attempts >= 5:
locked_until = (
datetime.now(timezone.utc) + timedelta(minutes=15)
datetime.now(UTC) + timedelta(minutes=15)
).isoformat()
updates["locked_until"] = locked_until
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
@@ -189,7 +198,7 @@ def is_locked(username: str) -> bool:
if not user or not user.get("locked_until"):
return False
locked_until = datetime.fromisoformat(user["locked_until"])
if datetime.now(timezone.utc) > locked_until:
if datetime.now(UTC) > locked_until:
# Lock expired — unlock
update_user(username, {"locked_until": None, "failed_attempts": 0})
return False
+1 -1
View File
@@ -591,7 +591,7 @@ async def api_bookslm_agent(
while True:
try:
kind, item = await asyncio.wait_for(queue.get(), timeout=0.25)
except asyncio.TimeoutError:
except TimeoutError:
if run_task.done():
break
continue
+215
View File
@@ -0,0 +1,215 @@
"""Chiffrement par enveloppe du coffre Secrets ObsiGate (Phase 1a).
Principe (spec §5) : deux niveaux de clés, jamais de valeur en clair au repos.
- KEK : clé maître 256 bits, fournie par l'opérateur via ``OBSIGATE_KEK``
(base64, 32 octets) ou ``OBSIGATE_KEK_FILE`` (fichier 0600, hors dépôt,
hors arborescence des vaults). Elle n'est JAMAIS dérivée du secret
d'application, jamais journalisée, jamais renvoyée par API.
- DEK : une clé aléatoire par coffre, stockée uniquement chiffrée par la KEK.
- Version de secret : ciphertext AES-256-GCM + nonce unique + tag GCM
(le tag est inclus dans le ciphertext renvoyé par ``AESGCM.encrypt``).
Un tag invalide ou un fichier altéré = refus, jamais de valeur fausse.
Le module reste importable sans ``cryptography`` installé : les opérations
échouent alors avec ``CryptoIndisponibleError`` au lieu de casser le
démarrage de l'application (le routeur répond 503 dans ce cas).
"""
from __future__ import annotations
import base64
import binascii
import logging
import os
import stat
from pathlib import Path
from typing import Any
logger = logging.getLogger("obsigate.coffre_crypto")
# Annotation explicite : sans elle, mypy refuse l'assignation de deux types
# (la classe AESGCM importée, puis None dans le repli sans cryptography)
# et le commentaire `type: ignore[assignment]` ne couvre pas ce code [misc].
AESGCM: type[Any] | None
try: # cryptography est une dépendance de la Phase 1a (requirements.txt)
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
except Exception: # pragma: no cover - environnement sans cryptography
AESGCM = None
InvalidTag = Exception # type: ignore[assignment,misc]
class CoffreCryptoError(Exception):
"""Erreur générique du chiffrement du coffre."""
class KEKAbsenteError(CoffreCryptoError):
"""Aucune KEK configurée (ni OBSIGATE_KEK, ni OBSIGATE_KEK_FILE)."""
class KEKInvalideError(CoffreCryptoError):
"""KEK mal formée (ni base64 32 octets, ni hex 64 caractères)."""
class TagInvalideError(CoffreCryptoError):
"""Authentification GCM échouée : données altérées ou mauvaise clé."""
class CryptoIndisponibleError(CoffreCryptoError):
"""La bibliothèque ``cryptography`` n'est pas installée."""
def _require_aesgcm() -> type[Any]:
"""Retourne la classe AESGCM, ou lève si cryptography est absent.
Retourner la classe (plutôt qu'un simple garde) permet à mypy de
savoir que l'appelant détient bien un constructible, et non None.
"""
if AESGCM is None:
raise CryptoIndisponibleError(
"cryptography n'est pas installé (dépendance Phase 1a)"
)
return AESGCM
def parse_kek(raw: str) -> bytes:
"""Parse une KEK : base64 (32 octets) ou hex (64 caractères).
Accepte le base64 standard et URL-safe, avec ou sans padding.
"""
value = (raw or "").strip()
if not value:
raise KEKInvalideError("KEK vide")
# Hex d'abord si la forme correspond (64 caractères hex).
if len(value) == 64:
try:
decoded = bytes.fromhex(value)
except ValueError:
decoded = b""
if len(decoded) == 32:
return decoded
padded = value + "=" * (-len(value) % 4)
for decoder in (base64.urlsafe_b64decode, base64.b64decode):
try:
decoded = decoder(padded)
except (binascii.Error, ValueError):
continue
if len(decoded) == 32:
return decoded
raise KEKInvalideError("KEK invalide : attendu base64 de 32 octets")
def generate_kek_b64() -> str:
"""Génère une KEK 256 bits, encodée base64 (à sauvegarder hors dépôt)."""
return base64.b64encode(os.urandom(32)).decode("ascii")
def load_kek() -> bytes:
"""Charge la KEK depuis l'environnement ou le fichier dédié.
Priorité : ``OBSIGATE_KEK`` puis ``OBSIGATE_KEK_FILE``. Le fichier doit
être en 0600 (aucun droit groupe/autre) ; sinon, refus explicite.
"""
env_kek = os.environ.get("OBSIGATE_KEK", "").strip()
if env_kek:
return parse_kek(env_kek)
kek_file = os.environ.get("OBSIGATE_KEK_FILE", "").strip()
if kek_file:
path = Path(kek_file)
try:
mode = path.stat().st_mode
except OSError as exc:
raise KEKAbsenteError(f"Fichier KEK illisible : {kek_file}") from exc
if mode & (stat.S_IRWXG | stat.S_IRWXO):
raise KEKInvalideError(
"Fichier KEK trop permissif : attendu 0600, "
f"droits groupe/autre détectés sur {kek_file}"
)
try:
return parse_kek(path.read_text(encoding="utf-8"))
except OSError as exc:
raise KEKAbsenteError(f"Fichier KEK illisible : {kek_file}") from exc
raise KEKAbsenteError("Aucune KEK configurée (OBSIGATE_KEK / OBSIGATE_KEK_FILE)")
def kek_presente() -> bool:
"""True si une KEK valide est chargeable, False sinon (jamais d'exception)."""
try:
load_kek()
except CoffreCryptoError:
return False
return True
def generate_dek() -> bytes:
"""Génère une DEK aléatoire 256 bits pour un coffre."""
return os.urandom(32)
def _b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def _unb64(value: str) -> bytes:
return base64.b64decode(value)
def encrypt_with_key(key: bytes, plaintext: bytes, aad: bytes) -> dict:
"""Chiffre avec AES-256-GCM. Retourne {nonce, ciphertext} en base64."""
aesgcm = _require_aesgcm()
nonce = os.urandom(12)
ciphertext = aesgcm(key).encrypt(nonce, plaintext, aad)
return {"nonce": _b64(nonce), "ciphertext": _b64(ciphertext)}
def decrypt_with_key(key: bytes, nonce_b64: str, ciphertext_b64: str, aad: bytes) -> bytes:
"""Déchiffre avec AES-256-GCM. Tag invalide -> TagInvalideError."""
aesgcm = _require_aesgcm()
try:
return aesgcm(key).decrypt(_unb64(nonce_b64), _unb64(ciphertext_b64), aad)
except InvalidTag as exc:
raise TagInvalideError("Tag GCM invalide : données altérées ou mauvaise clé") from exc
except (binascii.Error, ValueError) as exc:
raise TagInvalideError("Données chiffrées mal formées") from exc
def wrap_dek(dek: bytes, kek: bytes, coffre_id: str) -> dict:
"""Chiffre une DEK avec la KEK (AAD = identifiant du coffre)."""
return encrypt_with_key(kek, dek, coffre_id.encode("utf-8"))
def unwrap_dek(wrapped: dict, kek: bytes, coffre_id: str) -> bytes:
"""Déchiffre une DEK avec la KEK."""
return decrypt_with_key(
kek, wrapped["nonce"], wrapped["ciphertext"], coffre_id.encode("utf-8")
)
def encrypt_secret_value(dek: bytes, secret_id: str, version: int, valeur: str) -> dict:
"""Chiffre une valeur de secret (AAD = secret_id:version)."""
aad = f"{secret_id}:{version}".encode()
return encrypt_with_key(dek, valeur.encode("utf-8"), aad)
def decrypt_secret_value(
dek: bytes, secret_id: str, version: int, nonce_b64: str, ciphertext_b64: str
) -> str:
"""Déchiffre une valeur de secret."""
aad = f"{secret_id}:{version}".encode()
return decrypt_with_key(dek, nonce_b64, ciphertext_b64, aad).decode("utf-8")
def reencrypt_secret_value(
ancienne_dek: bytes,
nouvelle_dek: bytes,
secret_id: str,
version: int,
nonce_b64: str,
ciphertext_b64: str,
) -> dict:
"""Re-chiffre une version avec une nouvelle DEK (rotation DEK)."""
valeur = decrypt_secret_value(
ancienne_dek, secret_id, version, nonce_b64, ciphertext_b64
)
return encrypt_secret_value(nouvelle_dek, secret_id, version, valeur)
+306
View File
@@ -0,0 +1,306 @@
"""Migration Phase 2 du coffre Secrets ObsiGate (issue #20).
Bascule des clés aujourd'hui en clair dans ``data/api_keys.json`` vers le
coffre d'instance « ObsiGate interne » (slug ``obsigate-interne``).
Format source (constaté dans ``backend/ai.py`` et
``backend/tools/secrets.py``) : un dictionnaire plat ``{NOM: valeur}``.
Seules les entrées dont le nom ressemble à un secret sont migrables :
clés IA et clés d'outils. Les valeurs non secrètes (par exemple
``OBSIGATE_GITEA_URL``) ne sont JAMAIS migrées et ne sont jamais
touchées par la purge.
Règles de sécurité, non négociables :
- Aucune fonction de ce module ne renvoie une valeur, ne la journalise,
ni ne l'écrit ailleurs que dans le coffre (chiffrée, via le store).
Les aperçus ne contiennent que des noms, des types et ``has_value``.
- L'import ne supprime rien : il crée des secrets dans le coffre et
laisse ``api_keys.json`` intact. La purge est une étape séparée, qui
exige ``confirmer=True`` et ne supprime que les noms listés.
- Un secret déjà présent dans le coffre n'est JAMAIS écrasé : il est
signalé dans ``deja_existants`` et la valeur du coffre est conservée.
Limite connue, documentée dans ``docs/COFFRE-sauvegarde-restauration.md`` :
les lecteurs actuels (``backend/ai.py``, ``backend/tools/secrets.py``)
lisent encore ``api_keys.json`` (chemin relatif ``data/``, sans passer
par ``OBSIGATE_DATA_DIR``). Purger le clair sans avoir branché le
lecteur concerné sur le coffre rend donc la clé inaccessible à ce
lecteur : la purge reste un choix explicite de l'opérateur, confirmé
deux fois côté interface (import, puis purge).
"""
from __future__ import annotations
import json
import logging
import os
import threading
from pathlib import Path
from typing import Any
from backend.coffre_store import (
ValidationError,
create_coffre,
create_secret,
get_coffre,
get_secret,
resolve_data_dir,
)
logger = logging.getLogger("obsigate.coffre_migration")
_lock = threading.RLock()
COFFRE_INTERNE_SLUG = "obsigate-interne"
COFFRE_INTERNE_NOM = "ObsiGate interne"
# (nom dans api_keys.json, type de secret, service d'affichage).
# Miroir des clés lues par backend/ai.py (clés IA) et par
# backend/tools/secrets.py TOOL_KEY_NAMES (clés d'outils). L'URL Gitea
# est volontairement absente : ce n'est pas un secret.
CLES_MIGRABLES: tuple[tuple[str, str, str], ...] = (
("DEEPSEEK_API_KEY", "api_key", "DeepSeek"),
("OPENROUTER_API_KEY", "api_key", "OpenRouter"),
("GEMINI_API_KEY", "api_key", "Gemini"),
("NVIDIA_API_KEY", "api_key", "NVIDIA"),
("QWENCLOUD_API_KEY", "api_key", "QwenCloud"),
("XIAOMI_API_KEY", "api_key", "Xiaomi"),
("MISTRAL_API_KEY", "api_key", "Mistral"),
("OBSIGATE_TAVILY_API_KEY", "api_key", "Tavily"),
("OBSIGATE_BRAVE_API_KEY", "api_key", "Brave"),
("OBSIGATE_SERPAPI_API_KEY", "api_key", "SerpAPI"),
("OBSIGATE_EXA_API_KEY", "api_key", "Exa"),
("OBSIGATE_GITEA_TOKEN", "token", "Gitea"),
("OBSIGATE_GITHUB_TOKEN", "token", "GitHub"),
)
_NOMS_MIGRABLES = {nom for nom, _, _ in CLES_MIGRABLES}
def _nom_secret(nom_source: str) -> str:
"""Nom de secret dérivé du nom source (minuscules, valide NOM_SECRET_RE)."""
return nom_source.lower()
def _api_keys_path(base: Path) -> Path:
return base / "api_keys.json"
def _read_api_keys(base: Path) -> dict:
path = _api_keys_path(base)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
logger.warning("api_keys.json illisible (%s) : %s", path, exc)
return {}
return data if isinstance(data, dict) else {}
def _write_api_keys(base: Path, data: dict) -> None:
"""Écriture atomique tmp+rename, fichier final en 0600 (best-effort)."""
path = _api_keys_path(base)
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError:
pass
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
def lister_candidats(data_dir: str | Path | None = None) -> list:
"""Aperçu de migration : métadonnées seules, jamais de valeur.
Chaque candidat porte ``nom`` (nom dans le coffre), ``source`` (nom
dans api_keys.json), ``type``, ``service``, ``has_value`` (une valeur
non vide est présente dans le fichier) et ``deja_existant`` (un
secret de ce nom existe déjà dans le coffre d'instance).
"""
base = resolve_data_dir(data_dir)
with _lock:
cles = _read_api_keys(base)
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
candidats = []
for source, type_secret, service in CLES_MIGRABLES:
nom = _nom_secret(source)
valeur = cles.get(source)
candidats.append(
{
"nom": nom,
"source": source,
"type": type_secret,
"service": service,
"has_value": isinstance(valeur, str) and bool(valeur.strip()),
"deja_existant": (
get_secret(coffre["id"], nom, base) is not None
if coffre is not None
else False
),
}
)
return candidats
def importer_api_keys(
data_dir: str | Path | None = None, cree_par: str = ""
) -> dict:
"""Importe les entrées avec valeur vers le coffre d'instance.
Ne supprime rien dans ``api_keys.json``. Retourne uniquement des
noms et des comptes : ``migres``, ``deja_existants``, ``sans_valeur``.
Lève ``KEKAbsenteError`` / ``CryptoIndisponibleError`` si le coffre
ne peut pas chiffrer (le routeur les traduit en 503).
"""
base = resolve_data_dir(data_dir)
with _lock:
cles = _read_api_keys(base)
coffre = get_coffre(COFFRE_INTERNE_SLUG, base)
if coffre is None:
coffre = create_coffre(
slug=COFFRE_INTERNE_SLUG,
nom=COFFRE_INTERNE_NOM,
proprietaire=(cree_par or "admin").strip(),
perimetre="instance",
description="Coffre d'instance : clés IA et clés d'outils migrées depuis api_keys.json (Phase 2).",
data_dir=base,
)
migres: list[str] = []
deja_existants: list[str] = []
sans_valeur: list[str] = []
for source, type_secret, service in CLES_MIGRABLES:
nom = _nom_secret(source)
valeur = cles.get(source)
if not (isinstance(valeur, str) and valeur.strip()):
sans_valeur.append(nom)
continue
if get_secret(coffre["id"], nom, base) is not None:
deja_existants.append(nom)
continue
create_secret(
coffre,
nom,
type_secret,
valeur,
service=service,
note="Migré depuis api_keys.json (Phase 2).",
data_dir=base,
)
migres.append(nom)
return {
"coffre": COFFRE_INTERNE_SLUG,
"migres": migres,
"deja_existants": deja_existants,
"sans_valeur": sans_valeur,
}
def purger_api_keys(
noms: list[str] | None,
data_dir: str | Path | None = None,
confirmer: bool = False,
) -> dict:
"""Supprime du clair les noms listés, après confirmation explicite.
``confirmer`` doit valoir True, sinon refus (jamais automatique).
Seuls les noms migrables sont acceptés ; un nom inconnu refuse
l'opération entière, pour ne jamais supprimer une clé par erreur.
Retourne ``supprimes`` et ``absents`` (noms, jamais de valeurs).
"""
if not confirmer:
raise ValidationError(
"Suppression du clair refusée : confirmation explicite requise"
)
demandes = [(n or "").strip().lower() for n in (noms or [])]
# Accepte indifféremment le nom source (DEEPSEEK_API_KEY) ou le nom
# de secret (deepseek_api_key) ; tout le reste refuse l'opération.
noms_secrets = {_nom_secret(source) for source in _NOMS_MIGRABLES}
inconnus = [
n
for n in demandes
if n.upper() not in _NOMS_MIGRABLES and n not in noms_secrets
]
if inconnus:
raise ValidationError(f"Noms inconnus, purge annulée : {', '.join(inconnus)}")
sources_a_supprimer = set()
for demande in demandes:
for source, _, _ in CLES_MIGRABLES:
if demande in (source.lower(), _nom_secret(source)):
sources_a_supprimer.add(source)
base = resolve_data_dir(data_dir)
with _lock:
cles = _read_api_keys(base)
supprimes: list[str] = []
absents: list[str] = []
for source in sorted(sources_a_supprimer):
if source in cles:
del cles[source]
supprimes.append(_nom_secret(source))
else:
absents.append(_nom_secret(source))
_write_api_keys(base, cles)
return {"supprimes": supprimes, "absents": absents}
def creer_consommateur_avec_octrois(
nom: str,
octrois_demandes: list[dict[str, Any]] | None,
cree_par: str,
data_dir: str | Path | None = None,
) -> tuple[dict, str, list, list]:
"""Enregistre un consommateur et ses octrois en une seule opération.
Retourne (consommateur public, jeton à usage unique, octrois créés,
erreurs). Une erreur sur un octroi (coffre ou secret introuvable)
n'annule pas le consommateur ni les autres octrois : elle est
signalée dans ``erreurs`` (noms seuls, jamais de valeurs). Le coffre
lui-même n'est jamais modifié par cette opération.
"""
from backend.coffre_store import ( # import local : évite un cycle
IntrouvableError,
create_consommateur,
create_octroi,
)
base = resolve_data_dir(data_dir)
consommateur, jeton = create_consommateur(nom, cree_par, base)
crees: list[dict] = []
erreurs: list[dict] = []
for demande in octrois_demandes or []:
slug = str(demande.get("coffre_slug", "") or "")
secret_nom = str(demande.get("secret_nom", "") or "") or None
try:
coffre = get_coffre(slug, base)
if coffre is None:
raise IntrouvableError(f"Coffre introuvable : {slug}")
secret_id = None
if secret_nom is not None:
secret = get_secret(coffre["id"], secret_nom, base)
if secret is None:
raise IntrouvableError(f"Secret introuvable : {secret_nom}")
secret_id = secret["id"]
octroi = create_octroi(
coffre=coffre,
cible_type="consommateur",
cible_id=consommateur["id"],
secret_id=secret_id,
cree_par=cree_par,
expiration=demande.get("expiration"),
data_dir=base,
)
crees.append(octroi)
except (IntrouvableError, ValidationError) as exc:
erreurs.append(
{
"coffre_slug": slug,
"secret_nom": secret_nom,
"erreur": str(exc),
}
)
return consommateur, jeton, crees, erreurs
+860
View File
@@ -0,0 +1,860 @@
"""Store du coffre Secrets ObsiGate (Phase 1a).
Patron repris de ``backend/tools/secrets.py`` : JSON dans ``data/``,
écriture atomique (tmp + rename), verrou ``RLock`` sur les cycles
lecture-modification-écriture, répertoire honorant ``OBSIGATE_DATA_DIR``.
Fichiers (permissions 0600) :
- ``data/coffres.json`` : coffres (métadonnées + DEK chiffrées),
consommateurs (jetons hachés SHA-256 seulement, jamais en clair),
octrois (références, statuts, expirations).
- ``data/coffres_valeurs.json`` : versions chiffrées des secrets.
Aucune valeur en clair n'est jamais persistée ici, et aucune fonction de
ce module ne journalise de valeur ni de texte chiffré.
Modèle :
- Coffre : id, slug, nom, description, proprietaire, perimetre
(personnel | partage | instance), dek_chiffree (ou None en mode
dégradé sans KEK), statut (actif | archive), dates.
- Secret : id, coffre_id, nom, type, service, note, expiration,
version_courante, cree_le, modifie_le, derniere_utilisation.
- Version : numero, nonce, ciphertext, cree_le, remplacee_le.
Seule la version courante est distribuable.
- Consommateur : id (sert de jti), nom, jeton_hash, jeton_prefixe,
statut (actif | revoque), cree_par, cree_le.
- Octroi : id, coffre_id, secret_id (None = tout le coffre),
cible_type (consommateur | utilisateur | executeur | workspace),
cible_id, expiration, statut (actif | revoque | expire),
cree_par, cree_le, revoque_le.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import re
import secrets as _secrets
import threading
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from backend.coffre_crypto import (
decrypt_secret_value,
encrypt_secret_value,
generate_dek,
load_kek,
parse_kek,
reencrypt_secret_value,
unwrap_dek,
wrap_dek,
)
logger = logging.getLogger("obsigate.coffre_store")
_lock = threading.RLock()
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,63}$")
NOM_SECRET_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,127}$")
TYPES_SECRET = {"api_key", "token", "password", "certificate", "autre"}
PERIMETRES = {"personnel", "partage", "instance"}
CIBLES_OCTROI = {"consommateur", "utilisateur", "executeur", "workspace"}
MAX_VALEUR = 65536
MAX_TEXTE = 2000
DUREE_OCTROI_DEFAUT_JOURS = 90
# Motifs refusés dans les champs de métadonnées (description, note) :
# une valeur ressemblant à un secret ne doit jamais transiter par un champ
# qui, lui, est renvoyé en clair dans les métadonnées.
_SECRET_LIKE_PATTERNS = (
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
re.compile(r"\bsk-[A-Za-z0-9]{8,}\b"),
re.compile(r"\bgh[opsu]_[A-Za-z0-9]{8,}\b"),
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
re.compile(
r"(?i)\b(token|secret|mot_de_passe|password|api[-_]?key)\s*[:=]\s*\S+"
),
)
class StoreError(Exception):
"""Erreur générique du store."""
class CoffreExisteError(StoreError):
pass
class SecretExisteError(StoreError):
pass
class IntrouvableError(StoreError):
pass
class ValidationError(StoreError):
pass
class SecretExpireError(StoreError):
pass
def resolve_data_dir(data_dir: str | Path | None = None) -> Path:
"""Répertoire de persistance (OBSIGATE_DATA_DIR, défaut ``data``)."""
if data_dir is not None:
return Path(data_dir)
return Path(os.environ.get("OBSIGATE_DATA_DIR", "data"))
def _coffres_path(base: Path) -> Path:
return base / "coffres.json"
def _valeurs_path(base: Path) -> Path:
return base / "coffres_valeurs.json"
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _parse_date(value: str | None) -> datetime | None:
"""Parse une date ISO (datetime) ou une date seule (fin de journée UTC)."""
if value is None:
return None
text = str(value).strip()
if not text:
return None
if len(text) == 10:
text = text + "T23:59:59+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError as exc:
raise ValidationError(f"Date invalide : {value!r}") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed
def est_expire(expiration: str | None, maintenant: datetime | None = None) -> bool:
"""True si la date d'expiration est passée (None = jamais)."""
parsed = _parse_date(expiration)
if parsed is None:
return False
ref = maintenant or datetime.now(UTC)
return parsed <= ref
def _read_json(path: Path, default: dict) -> dict:
if not path.exists():
return json.loads(json.dumps(default))
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
logger.warning("Store coffre illisible (%s) : %s", path, exc)
return json.loads(json.dumps(default))
return data if isinstance(data, dict) else json.loads(json.dumps(default))
def _write_json(path: Path, data: dict) -> None:
"""Écriture atomique tmp+rename, fichier final en 0600."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(tmp, 0o600)
except OSError:
pass
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
def _load_coffres(base: Path) -> dict:
return _read_json(
_coffres_path(base),
{"coffres": [], "consommateurs": [], "octrois": []},
)
def _load_valeurs(base: Path) -> dict:
return _read_json(_valeurs_path(base), {"versions": {}})
def _save_coffres(base: Path, data: dict) -> None:
_write_json(_coffres_path(base), data)
def _save_valeurs(base: Path, data: dict) -> None:
_write_json(_valeurs_path(base), data)
def valider_slug(slug: str) -> str:
value = (slug or "").strip().lower()
if not SLUG_RE.match(value):
raise ValidationError(
"Slug invalide : 3 à 64 caractères, minuscules, chiffres, - ou _"
)
return value
def valider_nom_secret(nom: str) -> str:
value = (nom or "").strip().lower()
if not NOM_SECRET_RE.match(value):
raise ValidationError(
"Nom de secret invalide : 3 à 128 caractères, minuscules, chiffres, - ou _"
)
return value
def refuser_valeur_dans_metadonnees(texte: str | None, champ: str) -> None:
"""Refuse une valeur ressemblant à un secret dans un champ métadonnée."""
if not texte:
return
for motif in _SECRET_LIKE_PATTERNS:
if motif.search(texte):
raise ValidationError(
f"Le champ {champ} ressemble à une valeur de secret ; "
"utilisez le champ valeur du secret, jamais une métadonnée"
)
def _valider_texte(texte: str | None, champ: str) -> str:
value = (texte or "").strip()
if len(value) > MAX_TEXTE:
raise ValidationError(f"Le champ {champ} dépasse {MAX_TEXTE} caractères")
refuser_valeur_dans_metadonnees(value, champ)
return value
# ---------------------------------------------------------------------------
# Coffres
# ---------------------------------------------------------------------------
def list_coffres(data_dir: str | Path | None = None) -> list:
base = resolve_data_dir(data_dir)
with _lock:
return list(_load_coffres(base)["coffres"])
def get_coffre(slug: str, data_dir: str | Path | None = None) -> dict | None:
base = resolve_data_dir(data_dir)
slug = valider_slug(slug)
with _lock:
for coffre in _load_coffres(base)["coffres"]:
if coffre["slug"] == slug:
return coffre
return None
def get_coffre_or_404(slug: str, data_dir: str | Path | None = None) -> dict:
coffre = get_coffre(slug, data_dir)
if coffre is None:
raise IntrouvableError(f"Coffre introuvable : {slug}")
return coffre
def get_coffre_by_id(coffre_id: str, data_dir: str | Path | None = None) -> dict | None:
base = resolve_data_dir(data_dir)
with _lock:
for coffre in _load_coffres(base)["coffres"]:
if coffre["id"] == coffre_id:
return coffre
return None
def get_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict | None:
base = resolve_data_dir(data_dir)
with _lock:
for octroi in _load_coffres(base)["octrois"]:
if octroi["id"] == octroi_id:
return octroi
return None
def create_coffre(
slug: str,
nom: str,
proprietaire: str,
perimetre: str = "personnel",
description: str = "",
data_dir: str | Path | None = None,
) -> dict:
"""Crée un coffre. Sans KEK, la DEK reste None (mode dégradé) : les
métadonnées sont créées, mais aucun secret ne pourra être ajouté tant
que la KEK n'est pas configurée."""
base = resolve_data_dir(data_dir)
slug = valider_slug(slug)
if perimetre not in PERIMETRES:
raise ValidationError(f"Périmètre invalide : {perimetre}")
if not (proprietaire or "").strip():
raise ValidationError("Propriétaire requis")
description = _valider_texte(description, "description")
nom = (nom or "").strip()
if not nom or len(nom) > 128:
raise ValidationError("Nom de coffre requis (128 caractères max)")
with _lock:
data = _load_coffres(base)
if any(c["slug"] == slug for c in data["coffres"]):
raise CoffreExisteError(f"Coffre déjà existant : {slug}")
coffre: dict[str, Any] = {
"id": str(uuid.uuid4()),
"slug": slug,
"nom": nom,
"description": description,
"proprietaire": proprietaire.strip(),
"perimetre": perimetre,
"dek_chiffree": None,
"statut": "actif",
"cree_le": _now_iso(),
}
try:
kek = load_kek()
except Exception:
kek = None
if kek is not None:
coffre["dek_chiffree"] = wrap_dek(generate_dek(), kek, coffre["id"])
data["coffres"].append(coffre)
_save_coffres(base, data)
return coffre
def ensure_dek(coffre: dict, data_dir: str | Path | None = None) -> bytes:
"""Retourne la DEK en clair (mémoire seulement). Génère et persiste la
DEK si le coffre a été créé en mode dégradé et que la KEK est là."""
base = resolve_data_dir(data_dir)
kek = load_kek() # lève KEKAbsenteError si aucune KEK
if coffre.get("dek_chiffree"):
return unwrap_dek(coffre["dek_chiffree"], kek, coffre["id"])
with _lock:
data = _load_coffres(base)
for item in data["coffres"]:
if item["id"] == coffre["id"]:
if item.get("dek_chiffree"):
return unwrap_dek(item["dek_chiffree"], kek, item["id"])
dek = generate_dek()
item["dek_chiffree"] = wrap_dek(dek, kek, item["id"])
_save_coffres(base, data)
return dek
raise IntrouvableError("Coffre introuvable pour génération de DEK")
# Les secrets vivent dans coffres.json (métadonnées), leurs versions dans
# coffres_valeurs.json (chiffrées). Les deux chargements sont faits ici
# pour garder une seule source de vérité dans ce module.
def _load_tout(base: Path) -> tuple[dict, dict]:
data = _load_coffres(base)
if "secrets" not in data:
data["secrets"] = []
return data, _load_valeurs(base)
def compter_secrets(coffre_id: str, data_dir: str | Path | None = None) -> int:
base = resolve_data_dir(data_dir)
with _lock:
data, _ = _load_tout(base)
return sum(1 for s in data["secrets"] if s["coffre_id"] == coffre_id)
def list_secrets(coffre_id: str, data_dir: str | Path | None = None) -> list:
base = resolve_data_dir(data_dir)
with _lock:
data, _ = _load_tout(base)
return [s for s in data["secrets"] if s["coffre_id"] == coffre_id]
def get_secret(
coffre_id: str, nom: str, data_dir: str | Path | None = None
) -> dict | None:
base = resolve_data_dir(data_dir)
nom = valider_nom_secret(nom)
with _lock:
data, _ = _load_tout(base)
for secret in data["secrets"]:
if secret["coffre_id"] == coffre_id and secret["nom"] == nom:
return secret
return None
def secret_metadata(secret: dict, octrois_actifs: int = 0) -> dict:
"""Vue métadonnées seules d'un secret : jamais de valeur, jamais de
texte chiffré, jamais de nonce."""
return {
"id": secret["id"],
"nom": secret["nom"],
"type": secret["type"],
"service": secret.get("service", ""),
"note": secret.get("note", ""),
"expiration": secret.get("expiration"),
"expire": est_expire(secret.get("expiration")),
"version": secret["version_courante"],
"has_value": True,
"cree_le": secret["cree_le"],
"modifie_le": secret["modifie_le"],
"derniere_utilisation": secret.get("derniere_utilisation"),
"octrois_actifs": octrois_actifs,
"reference": f"coffre:{secret['coffre_slug']}/{secret['nom']}",
}
def create_secret(
coffre: dict,
nom: str,
type_secret: str,
valeur: str,
service: str = "",
expiration: str | None = None,
note: str = "",
data_dir: str | Path | None = None,
) -> dict:
"""Chiffre et stocke un secret (nouvelle version 1)."""
base = resolve_data_dir(data_dir)
nom = valider_nom_secret(nom)
if type_secret not in TYPES_SECRET:
raise ValidationError(f"Type de secret invalide : {type_secret}")
if not valeur or len(valeur) > MAX_VALEUR:
raise ValidationError("Valeur requise (64 KiB max)")
service = (service or "").strip()
if len(service) > 128:
raise ValidationError("Service trop long (128 caractères max)")
note = _valider_texte(note, "note")
if expiration is not None:
_parse_date(expiration)
dek = ensure_dek(coffre, base) # KEKAbsenteError si pas de KEK
with _lock:
data, valeurs = _load_tout(base)
if any(
s["coffre_id"] == coffre["id"] and s["nom"] == nom
for s in data["secrets"]
):
raise SecretExisteError(f"Secret déjà existant : {nom}")
secret_id = str(uuid.uuid4())
chiffre = encrypt_secret_value(dek, secret_id, 1, valeur)
secret = {
"id": secret_id,
"coffre_id": coffre["id"],
"coffre_slug": coffre["slug"],
"nom": nom,
"type": type_secret,
"service": service,
"note": note,
"expiration": expiration,
"version_courante": 1,
"cree_le": _now_iso(),
"modifie_le": _now_iso(),
"derniere_utilisation": None,
}
data["secrets"].append(secret)
valeurs["versions"][secret_id] = [
{
"numero": 1,
"nonce": chiffre["nonce"],
"ciphertext": chiffre["ciphertext"],
"cree_le": secret["cree_le"],
"remplacee_le": None,
}
]
_save_coffres(base, data)
_save_valeurs(base, valeurs)
return secret
def rotate_secret(
coffre: dict, nom: str, valeur: str, data_dir: str | Path | None = None
) -> dict:
"""Rotation : nouvelle version active immédiatement, l'ancienne n'est
plus distribuable (seule la version courante est lue à la distribution)."""
base = resolve_data_dir(data_dir)
nom = valider_nom_secret(nom)
if not valeur or len(valeur) > MAX_VALEUR:
raise ValidationError("Valeur requise (64 KiB max)")
dek = ensure_dek(coffre, base)
with _lock:
data, valeurs = _load_tout(base)
secret = None
for item in data["secrets"]:
if item["coffre_id"] == coffre["id"] and item["nom"] == nom:
secret = item
break
if secret is None:
raise IntrouvableError(f"Secret introuvable : {nom}")
nouvelle_version = secret["version_courante"] + 1
chiffre = encrypt_secret_value(dek, secret["id"], nouvelle_version, valeur)
versions = valeurs["versions"].setdefault(secret["id"], [])
for version in versions:
if version["remplacee_le"] is None:
version["remplacee_le"] = _now_iso()
versions.append(
{
"numero": nouvelle_version,
"nonce": chiffre["nonce"],
"ciphertext": chiffre["ciphertext"],
"cree_le": _now_iso(),
"remplacee_le": None,
}
)
secret["version_courante"] = nouvelle_version
secret["modifie_le"] = _now_iso()
_save_coffres(base, data)
_save_valeurs(base, valeurs)
return secret
def delete_secret(
coffre: dict, nom: str, data_dir: str | Path | None = None
) -> tuple[dict, list]:
"""Supprime un secret, ses versions et ses octrois (cascade)."""
base = resolve_data_dir(data_dir)
nom = valider_nom_secret(nom)
with _lock:
data, valeurs = _load_tout(base)
secret = None
for item in data["secrets"]:
if item["coffre_id"] == coffre["id"] and item["nom"] == nom:
secret = item
break
if secret is None:
raise IntrouvableError(f"Secret introuvable : {nom}")
data["secrets"] = [s for s in data["secrets"] if s["id"] != secret["id"]]
valeurs["versions"].pop(secret["id"], None)
octrois_supprimes = [
o for o in data["octrois"] if o.get("secret_id") == secret["id"]
]
data["octrois"] = [
o for o in data["octrois"] if o.get("secret_id") != secret["id"]
]
_save_coffres(base, data)
_save_valeurs(base, valeurs)
return secret, octrois_supprimes
def distribuer_valeur(
coffre: dict, secret: dict, data_dir: str | Path | None = None
) -> str:
"""Déchiffre la version courante d'un secret (après contrôles d'octroi
faits par le routeur). Refuse un secret expiré (410 côté routeur)."""
base = resolve_data_dir(data_dir)
if est_expire(secret.get("expiration")):
raise SecretExpireError("Secret expiré")
dek = ensure_dek(coffre, base)
with _lock:
_, valeurs = _load_tout(base)
versions = valeurs["versions"].get(secret["id"], [])
courante = None
for version in versions:
if version["numero"] == secret["version_courante"]:
courante = version
break
if courante is None:
raise IntrouvableError("Version courante introuvable")
return decrypt_secret_value(
dek,
secret["id"],
secret["version_courante"],
courante["nonce"],
courante["ciphertext"],
)
def toucher_utilisation(secret_id: str, data_dir: str | Path | None = None) -> None:
base = resolve_data_dir(data_dir)
with _lock:
data, _ = _load_tout(base)
for secret in data["secrets"]:
if secret["id"] == secret_id:
secret["derniere_utilisation"] = _now_iso()
_save_coffres(base, data)
return
def rotate_dek(coffre_slug: str, data_dir: str | Path | None = None) -> dict:
"""Rotation DEK : re-chiffre toutes les versions du coffre avec une
nouvelle DEK, puis remplace la DEK chiffrée du coffre."""
base = resolve_data_dir(data_dir)
coffre = get_coffre_or_404(coffre_slug, base)
kek = load_kek()
ancienne_dek = ensure_dek(coffre, base)
nouvelle_dek = generate_dek()
with _lock:
data, valeurs = _load_tout(base)
secrets_coffre = [s for s in data["secrets"] if s["coffre_id"] == coffre["id"]]
for secret in secrets_coffre:
nouvelles_versions = []
for version in valeurs["versions"].get(secret["id"], []):
nouvelles_versions.append(
{
**reencrypt_secret_value(
ancienne_dek,
nouvelle_dek,
secret["id"],
version["numero"],
version["nonce"],
version["ciphertext"],
),
"numero": version["numero"],
"cree_le": version["cree_le"],
"remplacee_le": version["remplacee_le"],
}
)
valeurs["versions"][secret["id"]] = nouvelles_versions
for item in data["coffres"]:
if item["id"] == coffre["id"]:
item["dek_chiffree"] = wrap_dek(nouvelle_dek, kek, item["id"])
coffre = item
break
_save_coffres(base, data)
_save_valeurs(base, valeurs)
return coffre
def rotate_kek(nouvelle_kek_b64: str, data_dir: str | Path | None = None) -> int:
"""Rotation KEK : re-chiffre uniquement les DEK avec la nouvelle KEK.
L'opérateur doit ensuite remplacer OBSIGATE_KEK / OBSIGATE_KEK_FILE
par la nouvelle valeur (cette fonction ne touche pas à l'environnement).
Retourne le nombre de coffres re-chiffrés."""
base = resolve_data_dir(data_dir)
ancienne_kek = load_kek()
nouvelle_kek = parse_kek(nouvelle_kek_b64)
count = 0
with _lock:
data = _load_coffres(base)
for coffre in data["coffres"]:
if not coffre.get("dek_chiffree"):
continue
dek = unwrap_dek(coffre["dek_chiffree"], ancienne_kek, coffre["id"])
coffre["dek_chiffree"] = wrap_dek(dek, nouvelle_kek, coffre["id"])
count += 1
_save_coffres(base, data)
return count
# ---------------------------------------------------------------------------
# Consommateurs (jetons serveur-à-serveur, hachés, révocables par jti)
# ---------------------------------------------------------------------------
def _hash_jeton(jeton: str) -> str:
return hashlib.sha256(jeton.encode("utf-8")).hexdigest()
def list_consommateurs(data_dir: str | Path | None = None) -> list:
base = resolve_data_dir(data_dir)
with _lock:
return [
{k: v for k, v in c.items() if k != "jeton_hash"}
for c in _load_coffres(base)["consommateurs"]
]
def create_consommateur(
nom: str, cree_par: str, data_dir: str | Path | None = None
) -> tuple[dict, str]:
"""Crée un consommateur et retourne (métadonnées, jeton à usage unique).
Le jeton n'est JAMAIS persisté : seul son hash SHA-256 est stocké."""
base = resolve_data_dir(data_dir)
nom = (nom or "").strip()
if not nom or len(nom) > 128:
raise ValidationError("Nom de consommateur requis (128 caractères max)")
jeton = "csk_" + _secrets.token_urlsafe(32)
with _lock:
data = _load_coffres(base)
consommateur = {
"id": str(uuid.uuid4()),
"nom": nom,
"jeton_hash": _hash_jeton(jeton),
"jeton_prefixe": jeton[:12],
"statut": "actif",
"cree_par": cree_par,
"cree_le": _now_iso(),
}
data["consommateurs"].append(consommateur)
_save_coffres(base, data)
public = {k: v for k, v in consommateur.items() if k != "jeton_hash"}
return public, jeton
def verifier_jeton_consommateur(
jeton: str, data_dir: str | Path | None = None
) -> dict | None:
"""Retourne le consommateur (sans hash) si le jeton correspond, sinon None."""
if not jeton:
return None
base = resolve_data_dir(data_dir)
cible = _hash_jeton(jeton)
with _lock:
for consommateur in _load_coffres(base)["consommateurs"]:
if hmac.compare_digest(consommateur.get("jeton_hash", ""), cible):
return {
k: v for k, v in consommateur.items() if k != "jeton_hash"
}
return None
def revoquer_consommateur(
consommateur_id: str, data_dir: str | Path | None = None
) -> dict:
base = resolve_data_dir(data_dir)
with _lock:
data = _load_coffres(base)
for consommateur in data["consommateurs"]:
if consommateur["id"] == consommateur_id:
consommateur["statut"] = "revoque"
_save_coffres(base, data)
return {
k: v
for k, v in consommateur.items()
if k != "jeton_hash"
}
raise IntrouvableError("Consommateur introuvable")
# ---------------------------------------------------------------------------
# Octrois
# ---------------------------------------------------------------------------
def list_octrois(coffre_id: str, data_dir: str | Path | None = None) -> list:
base = resolve_data_dir(data_dir)
with _lock:
return [
o for o in _load_coffres(base)["octrois"] if o["coffre_id"] == coffre_id
]
def compter_octrois_actifs(
secret_id: str, data_dir: str | Path | None = None
) -> int:
base = resolve_data_dir(data_dir)
with _lock:
return sum(
1
for o in _load_coffres(base)["octrois"]
if o.get("secret_id") == secret_id
and o["statut"] == "actif"
and not est_expire(o.get("expiration"))
)
def create_octroi(
coffre: dict,
cible_type: str,
cible_id: str,
secret_id: str | None,
cree_par: str,
expiration: str | None = None,
data_dir: str | Path | None = None,
) -> dict:
base = resolve_data_dir(data_dir)
if cible_type not in CIBLES_OCTROI:
raise ValidationError(f"Type de cible invalide : {cible_type}")
if not (cible_id or "").strip():
raise ValidationError("Cible d'octroi requise")
if secret_id is not None:
with _lock:
data = _load_coffres(base)
if not any(
s["id"] == secret_id and s["coffre_id"] == coffre["id"]
for s in data["secrets"]
):
raise IntrouvableError("Secret introuvable dans ce coffre")
if expiration is None:
expiration = (datetime.now(UTC) + timedelta(days=DUREE_OCTROI_DEFAUT_JOURS)).isoformat()
else:
_parse_date(expiration)
with _lock:
data = _load_coffres(base)
octroi = {
"id": str(uuid.uuid4()),
"coffre_id": coffre["id"],
"secret_id": secret_id,
"cible_type": cible_type,
"cible_id": cible_id.strip(),
"expiration": expiration,
"statut": "actif",
"cree_par": cree_par,
"cree_le": _now_iso(),
"revoque_le": None,
}
data["octrois"].append(octroi)
_save_coffres(base, data)
return octroi
def revoquer_octroi(octroi_id: str, data_dir: str | Path | None = None) -> dict:
"""Révocation : statut -> revoque (conservé pour distinguer
octroi_revoque de octroi_absent à la distribution)."""
base = resolve_data_dir(data_dir)
with _lock:
data = _load_coffres(base)
for octroi in data["octrois"]:
if octroi["id"] == octroi_id:
octroi["statut"] = "revoque"
octroi["revoque_le"] = _now_iso()
_save_coffres(base, data)
return octroi
raise IntrouvableError("Octroi introuvable")
def statut_octroi_consommateur(
consommateur_id: str,
coffre_id: str,
secret_id: str,
data_dir: str | Path | None = None,
) -> tuple[str, dict | None]:
"""Statut d'accès d'un consommateur à un secret.
Retourne ("ok" | "revoque" | "expire" | "absent", octroi). Un octroi
coffre-wide (secret_id None) couvre tous les secrets du coffre.
Priorité : ok > revoque > expire > absent (un octroi révoqué ne doit
jamais être masqué par l'absence d'un autre octroi)."""
base = resolve_data_dir(data_dir)
with _lock:
candidats = [
o
for o in _load_coffres(base)["octrois"]
if o["cible_type"] == "consommateur"
and o["cible_id"] == consommateur_id
and o["coffre_id"] == coffre_id
and (o.get("secret_id") is None or o["secret_id"] == secret_id)
]
for octroi in candidats:
if octroi["statut"] == "actif" and not est_expire(octroi.get("expiration")):
return "ok", octroi
for octroi in candidats:
if octroi["statut"] == "revoque":
return "revoque", octroi
for octroi in candidats:
if octroi["statut"] == "actif" and est_expire(octroi.get("expiration")):
return "expire", octroi
return "absent", None
def secrets_accessibles_utilisateur(
username: str, coffre: dict, data_dir: str | Path | None = None
) -> set | None:
"""Secrets d'un coffre visibles par un utilisateur via octroi.
Retourne None si un octroi coffre-wide actif existe (tout le coffre),
sinon l'ensemble des ids de secrets octroyés, sinon l'ensemble vide."""
base = resolve_data_dir(data_dir)
with _lock:
octrois = [
o
for o in _load_coffres(base)["octrois"]
if o["cible_type"] == "utilisateur"
and o["cible_id"] == username
and o["coffre_id"] == coffre["id"]
and o["statut"] == "actif"
and not est_expire(o.get("expiration"))
]
if any(o.get("secret_id") is None for o in octrois):
return None
return {o["secret_id"] for o in octrois if o.get("secret_id")}
+207
View File
@@ -0,0 +1,207 @@
"""URLs publiques de l'écosystème dracodev — par configuration (#223).
Règle dev/prod (Bruno, 2026-10-25) : **aucun nom public de
l'écosystème n'est figé dans le code vivant**. Chaque URL publique
vient de l'environnement ; les valeurs par défaut sont les noms
**officiels** (sans suffixe) de la future production. L'environnement
dev déclare ses noms en ``-dev`` dans son seul ``.env`` — le code
n'en connaît aucun.
Variables (voir ``.env.example``) :
- ``DRACO_AGENT_URL`` — base publique du service d'agents ;
à défaut, l'origine est **dérivée** de ``AGENT_EVENEMENTS_URL``
(la configuration réelle de l'émission d'événements, #220), puis
le défaut officiel.
- ``DRACO_OBSIGATE_URL`` — base publique d'ObsiGate (liens profonds).
- ``DRACO_FLOWDECK_URL`` — base publique de Flowdeck.
- ``DRACO_TIKTAK_URL`` — base publique de TikTak.
- ``DRACO_KANBANTE_URL`` — base publique de Kanbante.
- ``DRACO_SHAARIT_URL`` — base publique de ShaarIt (barre v0.2.0).
- ``DRACO_HUB_URL`` — base publique du Hub (barre v0.3.0,
HUB-1 : liens Télécharger / À Propos / Aides / Profil du menu
compte ; le Hub n'a pas d'onglet dans la barre).
- ``DRACO_GITEA_URL`` — base publique de Gitea (les cartes
Kanbante *sont* des issues Gitea, contrat liens-profonds) ;
distincte de ``OBSIGATE_GITEA_URL`` (source connectée de
l'assistant, réglable depuis l'UI, pas une URL d'affichage).
Résolution d'une URL sœur (BUG-120) : la variable explicite prime ;
à défaut, l'**environnement est dérivé de l'hôte de l'agent** tel
qu'ObsiGate le connaît déjà (:func:`agent_url`) — si cet hôte est de
la forme ``agent<SUFFIXE>.dracodev.net`` (``-dev`` en dev, vide en
prod), l'URL sœur est ``https://<slug><SUFFIXE>.dracodev.net``. Un
hôte hors motif (autre domaine, LAN, IP, port) ne dérive **rien** :
on ne fabrique jamais un lien deviné, le défaut officiel s'applique.
L'installation pointe donc les bons liens selon son environnement
sans déclarer les quatre ``DRACO_<APP>_URL``.
Ce module ne lit l'environnement qu'à l'appel (jamais à l'import) :
les tests et un changement de ``.env`` suivi d'un redémarrage
prennent effet sans état figé.
"""
from __future__ import annotations
import os
import re
from urllib.parse import urlparse
# Défauts officiels (production future) — jamais de nom en ``-dev`` ici.
DEFAUTS: dict[str, str] = {
"agent": "https://agent.dracodev.net",
"obsigate": "https://og.dracodev.net",
"flowdeck": "https://flowdeck.dracodev.net",
"tiktak": "https://tiktak.dracodev.net",
"kanbante": "https://kanbante.dracodev.net",
"shaarit": "https://shaarit.dracodev.net",
"hub": "https://hub.dracodev.net",
"gitea": "https://git.dracodev.net",
}
_VARIABLES: dict[str, str] = {
"agent": "DRACO_AGENT_URL",
"obsigate": "DRACO_OBSIGATE_URL",
"flowdeck": "DRACO_FLOWDECK_URL",
"tiktak": "DRACO_TIKTAK_URL",
"kanbante": "DRACO_KANBANTE_URL",
"shaarit": "DRACO_SHAARIT_URL",
"hub": "DRACO_HUB_URL",
"gitea": "DRACO_GITEA_URL",
}
# Slugs d'hôte des applications sœurs pour la dérivation
# d'environnement (BUG-120) : premier label de ``<slug>.dracodev.net``
# (et de ``<slug>-dev.dracodev.net`` en dev). Gitea est **exclue** :
# son URL ne se dérive jamais.
_SLUGS_HOTE: dict[str, str] = {
"obsigate": "og",
"flowdeck": "flowdeck",
"tiktak": "tiktak",
"kanbante": "kanbante",
"shaarit": "shaarit",
"hub": "hub",
}
# Premier label de l'hôte de l'agent : ``agent`` suivi d'un suffixe
# d'environnement éventuel (``-dev`` en dev, vide en prod).
_MOTIF_AGENT = re.compile(r"^agent(-[a-z0-9-]*)?$")
def _nettoyer(valeur: str) -> str:
return valeur.strip().rstrip("/")
def origine(url: str) -> str:
"""Origine ``schéma://hôte[:port]`` d'une URL (``""`` si illisible)."""
u = urlparse(url or "")
if u.scheme and u.netloc:
return f"{u.scheme}://{u.netloc}"
return ""
def agent_url() -> str:
"""Base publique du service d'agents.
Priorité : ``DRACO_AGENT_URL``, puis l'origine de
``AGENT_EVENEMENTS_URL`` (déjà la configuration réelle du poste),
puis le défaut officiel.
"""
explicite = _nettoyer(os.environ.get(_VARIABLES["agent"], ""))
if explicite:
return explicite
evenements = _nettoyer(os.environ.get("AGENT_EVENEMENTS_URL", ""))
if evenements:
derivee = origine(evenements)
if derivee:
return derivee
return DEFAUTS["agent"]
def agent_origine() -> str:
"""Origine du service d'agents, pour la CSP (``connect-src``/``frame-src``)."""
return origine(agent_url()) or DEFAUTS["agent"]
def _suffixe_environnement() -> str | None:
"""Suffixe d'environnement déduit de l'hôte de l'agent (BUG-120).
Lit l'hôte de :func:`agent_url` — l'origine de l'agent telle
qu'ObsiGate la connaît déjà (``DRACO_AGENT_URL``, à défaut
``AGENT_EVENEMENTS_URL``). Si cet hôte est exactement de la forme
``agent<SUFFIXE>.dracodev.net`` sans port, retourne ``SUFFIXE``
(``-dev`` en dev, ``""`` en prod). Sinon (autre domaine, LAN, IP,
port exotique) retourne ``None`` : aucune dérivation, on ne
fabrique jamais un lien deviné.
"""
u = urlparse(agent_url())
hote = (u.hostname or "").lower()
labels = hote.split(".")
if len(labels) != 3 or labels[1:] != ["dracodev", "net"]:
return None
try:
if u.port is not None:
return None
except ValueError:
return None
motif = _MOTIF_AGENT.match(labels[0])
if motif is None:
return None
return motif.group(1) or ""
def app_url(app: str) -> str:
"""Base publique d'une application de l'écosystème (sans ``/`` final).
Résolution d'une URL sœur (BUG-120) : ``DRACO_<APP>_URL``
explicite d'abord ; à défaut, dérivation d'environnement depuis
l'hôte de l'agent (:func:`_suffixe_environnement`) ; à défaut,
le défaut officiel. L'URL Gitea ne se dérive jamais.
"""
if app == "agent":
return agent_url()
variable = _VARIABLES[app]
explicite = _nettoyer(os.environ.get(variable, ""))
if explicite:
return explicite
if app in _SLUGS_HOTE:
suffixe = _suffixe_environnement()
if suffixe is not None:
return f"https://{_SLUGS_HOTE[app]}{suffixe}.dracodev.net"
return DEFAUTS[app]
def app_hote(app: str) -> str:
"""Nom d'hôte (minuscules) de l'URL publique configurée de *app*."""
return (urlparse(app_url(app)).hostname or "").lower()
def hotes_reconnus() -> dict[str, str]:
"""``{hôte: app}`` pour le parseur de liens profonds.
Contient **toujours** les hôtes officiels (un lien collé depuis la
production reste reconnaissable en dev, et réciproquement) **plus**
l'hôte résolu de chaque application (les noms en ``-dev`` du poste,
déclarés dans son ``.env`` ou dérivés de l'hôte de l'agent).
"""
hotes: dict[str, str] = {}
for app, defaut in DEFAUTS.items():
hote = (urlparse(defaut).hostname or "").lower()
if hote:
hotes[hote] = app
for app in DEFAUTS:
hote = app_hote(app)
if hote:
hotes[hote] = app
return hotes
def urls_publiques() -> dict[str, str]:
"""Carte des URL publiques injectée à l'hôte web (``window.DRACO_URLS``).
C'est le contrat d'injection de dracodev-commons : l'application
hôte (ici ObsiGate, qui sert ``index.html``) fournit les URL aux
assets partagés (panneau Spark, barre, liens) — les assets ne
portent que les défauts officiels en repli.
"""
return {app: app_url(app) for app in DEFAUTS}
+157 -1
View File
@@ -19,6 +19,27 @@
# vide = cookie lié à l'hôte, utile en dev)
# DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la
# liste de révocation (GET /api/sso/revocations)
#
# Registre des émissions (HUB-13, fusion #127) : chaque jti émis est
# consigné par `sub` dans data/draco_issued.json (jti -> exp, purgé à
# l'expiration) pour que l'administration des comptes puisse révoquer
# toutes les sessions draco d'un utilisateur (désactivation,
# suppression, réinitialisation du mot de passe) via revoke().
# HUB-22 (#227) ajoute la lecture de ce registre
# (issued_sessions_for_user) et la révocation unitaire contrôlée par
# appartenance (revoke_one_for_user), exposées par l'API machine des
# sessions (backend/routers/comptes_machine.py).
#
# Rôles du Hub (HUB-19, #228) : à l'ouverture de session draco,
# `user_for_draco` consulte en plus le registre des rôles du Hub
# (`backend/hub_roles.py`, contrat D3(a) posé par le pilote ShaarIt) :
# niveau connu ``aucun`` → la session draco est refusée (l'authenti-
# fication native d'ObsiGate, émettrice, n'est jamais touchée) ;
# niveau connu → il est joint au dict utilisateur (``_hub_role``)
# pour que les routes d'administration du canal draco exigent
# ``administrateur`` ; rôle inconnu (Hub injoignable) → comportement
# actuel, aucun verrouillage nouveau. Aucun rôle dans le jeton :
# validation locale, émission et révocations sont inchangées.
from __future__ import annotations
@@ -40,10 +61,12 @@ ALGORITHM = "HS256"
TOKEN_TTL_SECONDS = 12 * 3600 # durée de vie du jeton (design §5.2)
RENEWAL_WINDOW_SECONDS = 2 * 3600 # réémettre si exp - now < 2 h
REVOKED_FILE = Path("data/draco_revoked.json")
ISSUED_FILE = Path("data/draco_issued.json")
ALLOWED_RETURN_SUFFIX = "dracodev.net"
_lock = threading.Lock()
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
_issued: dict[str, dict[str, int]] | None = None # sub -> {jti -> exp}
def _secret() -> str:
@@ -108,6 +131,115 @@ def revoked_list() -> list[dict]:
for jti, exp in _load_revoked().items() if exp > now]
# ── Registre des émissions (par utilisateur) ────────────────────────────
def _load_issued() -> dict[str, dict[str, int]]:
global _issued
if _issued is None:
try:
data = json.loads(ISSUED_FILE.read_text(encoding="utf-8"))
_issued = {
str(sub): {str(j): int(e) for j, e in tokens.items()}
for sub, tokens in data.items()
if isinstance(tokens, dict)
} if isinstance(data, dict) else {}
except (OSError, ValueError):
_issued = {}
return _issued
def _save_issued() -> None:
ISSUED_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = ISSUED_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(_issued or {}), encoding="utf-8")
tmp.replace(ISSUED_FILE)
def _register_issued(sub: str, jti: str, exp: int) -> None:
"""Consigne un jti émis pour ``sub`` (purge des expirés au passage)."""
if not sub:
return
try:
with _lock:
store = _load_issued()
now = int(time.time())
for key in list(store.keys()):
store[key] = {j: e for j, e in store[key].items() if e > now}
if not store[key]:
del store[key]
store.setdefault(sub, {})[jti] = int(exp)
_save_issued()
except Exception: # le registre ne casse jamais une émission
logger.warning("Registre des émissions draco non écrit", exc_info=True)
def revoke_all_for_user(sub: str) -> int:
"""Révoque tous les jti draco actifs émis pour ``sub``.
Retourne le nombre de jti révoqués. Utilisé par l'administration
des comptes (HUB-13) : désactivation, suppression, réinitialisation
du mot de passe.
"""
if not sub:
return 0
with _lock:
store = _load_issued()
tokens = store.pop(str(sub), {})
now = int(time.time())
actifs = {j: e for j, e in tokens.items() if e > now}
if tokens:
_save_issued()
for jti, exp in actifs.items():
revoke(jti, exp)
return len(actifs)
def issued_sessions_for_user(sub: str) -> list[dict]:
"""Sessions draco actives émises pour ``sub`` (HUB-22, #227).
Retourne ``[{jti, exp, revoquee}]`` triée par ``exp`` croissant,
où ``revoquee`` indique si le ``jti`` figure déjà dans la liste des
révocations. Les entrées expirées de cet utilisateur sont purgées
du registre au passage (même patron que ``_register_issued``).
"""
if not sub:
return []
with _lock:
store = _load_issued()
now = int(time.time())
tokens = store.get(str(sub), {})
actifs = {j: e for j, e in tokens.items() if e > now}
if len(actifs) != len(tokens):
if actifs:
store[str(sub)] = actifs
else:
store.pop(str(sub), None)
_save_issued()
revoques = _load_revoked()
entrees = sorted(actifs.items(), key=lambda kv: kv[1])
return [{"jti": jti, "exp": exp, "revoquee": jti in revoques}
for jti, exp in entrees]
def revoke_one_for_user(sub: str, jti: str) -> bool:
"""Révoque un seul ``jti`` draco de ``sub`` (HUB-22, #227).
Ne révoque (via ``revoke``) que si le ``jti`` appartient bien à
l'ensemble émis de ce ``sub`` et n'est pas expiré — jamais de
révocation d'une session d'un autre utilisateur. Retourne ``True``
si la révocation a eu lieu, ``False`` sinon.
"""
if not sub or not jti:
return False
with _lock:
store = _load_issued()
exp = store.get(str(sub), {}).get(jti)
if exp is None or exp <= int(time.time()):
return False
revoke(jti, exp)
return True
# ── Émission / vérification ─────────────────────────────────────────────
def issue_token(user: dict) -> tuple[str, str, int]:
@@ -122,7 +254,9 @@ def issue_token(user: dict) -> tuple[str, str, int]:
"exp": exp,
"jti": jti,
}
return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp
token = jwt.encode(claims, _secret(), algorithm=ALGORITHM)
_register_issued(str(claims["sub"]), jti, exp)
return token, jti, exp
def decode_claims(token: str) -> dict | None:
@@ -211,6 +345,28 @@ def user_for_draco(request) -> dict | None:
user["_token_vaults"] = candidate.get("vaults", [])
user["_token_scope"] = "full"
user["_draco_sub"] = sub
# HUB-19 (#228) : rôle du registre du Hub pour ce porteur.
# Niveau connu « aucun » → pas de session établie par draco :
# le refus est consigné sur la requête pour que `require_auth`
# réponde un message explicite en français (et non un simple
# 401 générique). Rôle connu → joint au dict (`_hub_role`) ;
# rôle inconnu → `_hub_role` vaut None et le comportement
# actuel s'applique, sans verrouillage nouveau.
from backend import hub_roles
cookie = ""
try:
cookie = request.headers.get("cookie", "") or ""
except Exception: # un objet requête minimal (tests) ne casse rien
cookie = ""
role = hub_roles.role_pour(sub, cookie)
if role == "aucun":
try:
request.state.role_hub_refuse = "aucun"
except Exception: # pas d'état mutable : refus quand même
pass
return None
user["_hub_role"] = role
return user
return None
+2 -2
View File
@@ -359,7 +359,7 @@ def export_epub(vault_path: Path, file_path: Path) -> bytes:
rendered = _render_body(body, path.parent, vault_path, path)
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())}"
uid = f"obsigate-{_slugify(title)}-{int(datetime.datetime.now(tz=datetime.UTC).timestamp())}"
xhtml = f"""<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
@@ -383,7 +383,7 @@ def export_epub(vault_path: Path, file_path: Path) -> bytes:
<dc:title>{html_mod.escape(title)}</dc:title>
<dc:creator>{html_mod.escape(author)}</dc:creator>
<dc:language>fr</dc:language>
<meta property="dcterms:modified">{datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
<meta property="dcterms:modified">{datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%dT%H:%M:%SZ')}</meta>
</metadata>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
+76 -10
View File
@@ -24,7 +24,7 @@ import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, unquote, urlparse
from urllib.parse import parse_qs, quote, unquote, urlparse
logger = logging.getLogger("obsigate.external_refs")
@@ -64,26 +64,87 @@ def _write_refs(username: str, refs: list[dict[str, Any]]) -> None:
# ------------------------------------------------------------ parseur d'URL
def build_deep_link(target_app: str, target_type: str, target_id: str) -> str | None:
"""Construit l'URL profonde d'affichage depuis la **configuration**
courante (#223), ou ``None`` si la forme est inconnue.
Les références stockées portent une URL absolue figée au moment du
collage (voir ``add_ref``) : elle peut nommer l'autre environnement
(un lien collé en prod, lu en dev). L'affichage ne l'utilise donc
jamais telle quelle — il reconstruit l'URL depuis
``(target_app, target_type, target_id)`` et les bases publiques
configurées (``backend.draco_config``), au moment de la lecture.
Aucune donnée stockée n'est migrée ni réécrite.
"""
from backend.draco_config import app_url
if target_app == "tiktak":
if target_type == "task":
return f"{app_url('tiktak')}/?task={quote(target_id, safe='')}"
if target_type == "list":
return f"{app_url('tiktak')}/?list={quote(target_id, safe='')}"
return None
if target_app == "obsigate" and target_type == "note":
return f"{app_url('obsigate')}/#file={quote(target_id, safe=':')}"
if target_app == "flowdeck":
if target_type == "page":
return f"{app_url('flowdeck')}/pages/{quote(target_id, safe='')}"
if target_type == "project":
return f"{app_url('flowdeck')}/collections/{quote(target_id, safe='')}"
return None
if target_app == "kanbante" and target_type == "card":
m = re.fullmatch(r"([^/]+)/([^/#]+)#(\d+)", target_id or "")
if m:
owner, repo, num = m.groups()
return f"{app_url('gitea')}/{owner}/{repo}/issues/{num}"
return None
return None
def _avec_url_affichee(ref: dict[str, Any]) -> dict[str, Any]:
"""Copie de la référence dont ``target_url`` est reconstruite
depuis la config courante (#223) — le stockage n'est pas touché."""
construite = build_deep_link(
str(ref.get("target_app") or ""),
str(ref.get("target_type") or ""),
str(ref.get("target_id") or ""),
)
if construite is None:
return ref
copie = dict(ref)
copie["target_url"] = construite
return copie
def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
"""Reconnaît une URL profonde des applications dracodev.
Formes figées par ``dracodev-commons/contrats/liens-profonds.md``.
Retourne ``(target_app, target_type, target_id, label_par_défaut)``
ou ``None`` si l'URL ne correspond à aucune forme du contrat.
Hôtes reconnus (#223) : les hôtes **officiels** et les hôtes
**configurés** de chaque application (``backend.draco_config``) —
un lien collé depuis l'environnement courant (noms en ``-dev``
déclarés dans son ``.env``) comme depuis l'autre environnement
est donc reconnu ; aucun nom d'environnement n'est figé ici.
"""
from backend.draco_config import hotes_reconnus
try:
u = urlparse((url or "").strip())
except ValueError:
return None
host = (u.hostname or "").lower()
if host == "tiktak.dracodev.net":
app = hotes_reconnus().get(host)
if app == "tiktak":
q = parse_qs(u.query)
if q.get("task"):
return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}")
if q.get("list"):
return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}")
return None
if host == "og.dracodev.net":
if app == "obsigate":
frag = u.fragment or ""
if frag.startswith("file="):
cible = unquote(frag[5:])
@@ -91,7 +152,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
nom = cible.rsplit("/", 1)[-1]
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
return None
if host == "flowdeck.dracodev.net":
if app == "flowdeck":
m = re.fullmatch(r"/pages/(\d+)", u.path or "")
if m:
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
@@ -99,7 +160,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
if m:
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
return None
if host == "git.dracodev.net":
if app == "gitea":
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
if m:
owner, repo, num = m.groups()
@@ -111,20 +172,25 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
# ------------------------------------------------------------ CRUD
def list_refs(username: str, vault: str, path: str) -> list[dict[str, Any]]:
"""Références posées sur une note, dans l'ordre de création."""
"""Références posées sur une note, dans l'ordre de création.
``target_url`` de chaque référence retournée est **reconstruite
depuis la config courante** (#223, ``_avec_url_affichee``) : la
valeur stockée, figée au collage, n'est jamais servie telle quelle.
"""
if not username:
return []
return [
r for r in _read_refs(username)
_avec_url_affichee(r) for r in _read_refs(username)
if r.get("owner_vault") == vault and r.get("owner_path") == path
]
def get_ref(username: str, ref_id: str) -> dict[str, Any] | None:
"""Une référence par son identifiant, ou ``None``."""
"""Une référence par son identifiant, ou ``None`` (URL reconstruite, #223)."""
for r in _read_refs(username):
if r.get("id") == ref_id:
return r
return _avec_url_affichee(r)
return None
@@ -169,4 +235,4 @@ def delete_ref(username: str, ref_id: str) -> dict[str, Any] | None:
return None
removed = next(r for r in refs if r.get("id") == ref_id)
_write_refs(username, kept)
return removed
return _avec_url_affichee(removed)
+1 -1
View File
@@ -355,7 +355,7 @@ def _export_footer(lang: str) -> str:
template = loc.get("guide105.export_footer", "")
if "%s" not in template and "{" not in template:
template = "ObsiGate {version}"
today = datetime.datetime.now(tz=datetime.timezone.utc).date().isoformat()
today = datetime.datetime.now(tz=datetime.UTC).date().isoformat()
return _collapse(template).format(version=_app_version(), date=today)
+126
View File
@@ -0,0 +1,126 @@
# backend/hub_roles.py
# Hub HUB-19 (propagation, #228) — Rôles par application : consommation
# du registre du Hub par ObsiGate, sur le contrat posé par l'hôte
# pilote ShaarIt (0.7.0, décision D3(a) du plan Hub v2).
#
# À l'ouverture de session draco (validation locale du cookie partagé,
# `draco_session.user_for_draco`), ObsiGate demande au Hub le niveau
# du porteur pour l'application `obsigate` :
# ``GET {hub}/api/roles/obsigate``, en relayant l'en-tête Cookie
# complet de la requête, ``Accept: application/json``, délai 2 s.
# Réponse 200 : ``{"app", "sub", "role", "explicite",
# "ttl_secondes": 60}``, rôle ∈ {aucun, utilisateur, administrateur}.
#
# Cache mémoire par `sub`, TTL 60 s, sur le patron du pilote (et du
# cache de révocations draco) : l'horodatage est posé **avant**
# l'appel — un échec conserve l'ancien cache et ne déclenche pas de
# rafale de requêtes vers un Hub en panne.
#
# **Repli obligatoire** : Hub injoignable, en erreur ou réponse
# illisible → rôle **inconnu** (``None``) et l'appelant garde son
# comportement actuel. Le registre ne crée jamais un verrouillage
# nouveau.
#
# URL du Hub : `draco_config.app_url("hub")` — la même résolution que
# la barre commune (#223) : variable explicite `DRACO_HUB_URL` d'abord,
# dérivation par l'hôte sœur (suffixe d'environnement de l'hôte de
# l'agent) ensuite, défaut officiel enfin. Jamais d'URL dev figée.
from __future__ import annotations
import json
import logging
import threading
import time
import urllib.request
logger = logging.getLogger("obsigate.hub_roles")
#: Identifiant d'ObsiGate dans le registre des applications du Hub.
APP = "obsigate"
#: Niveaux reconnus du registre (contrat D3(a)).
ROLES = ("aucun", "utilisateur", "administrateur")
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub (60 s).
CACHE_TTL_SECONDS = 60
#: Délai maximal de la consultation du Hub (contrat : 2 s).
DELAI_SECONDS = 2
_lock = threading.Lock()
_cache: dict[str, str] = {} # sub -> rôle connu
_cache_at: float | None = None # horodatage (monotone) du dernier appel
def _hub_url() -> str:
"""Base publique du Hub, résolue comme pour la barre commune."""
from backend.draco_config import app_url
return app_url("hub")
def _consulter_hub(url: str, cookie: str) -> str | None:
"""Appelle le Hub et retourne le rôle validé, ou ``None``.
Toute erreur (réseau, délai dépassé, statut ≠ 200, JSON illisible,
rôle hors contrat) retourne ``None`` : rôle inconnu, repli.
``cookie`` est l'en-tête Cookie complet de la requête, relayé tel
quel — c'est lui qui prouve l'identité du porteur au Hub.
"""
requete = urllib.request.Request(
url,
method="GET",
headers={"Cookie": cookie, "Accept": "application/json"},
)
try:
with urllib.request.urlopen(requete, timeout=DELAI_SECONDS) as reponse:
if reponse.status != 200:
return None
donnees = json.loads(reponse.read().decode("utf-8"))
except Exception: # le registre ne casse jamais une ouverture de session
return None
if not isinstance(donnees, dict):
return None
role = donnees.get("role")
return role if role in ROLES else None
def role_pour(sub: str, cookie: str) -> str | None:
"""Niveau connu pour ce `sub` : un des ``ROLES`` si le Hub a
répondu, ``None`` si le rôle est inconnu (Hub injoignable/erreur)
— repli = comportement actuel de l'appelant.
`cookie` est l'en-tête Cookie complet de la requête d'origine,
relayé tel quel au Hub.
"""
global _cache_at
if not sub:
return None
with _lock:
if _cache_at is not None \
and time.monotonic() - _cache_at < CACHE_TTL_SECONDS \
and sub in _cache:
return _cache[sub]
# Horodatage posé AVANT l'appel (patron du pilote) : l'ancien
# cache reste en vigueur, un échec ne déclenche pas de rafale.
_cache_at = time.monotonic()
url = f"{_hub_url().rstrip('/')}/api/roles/{APP}"
role = _consulter_hub(url, cookie)
if role is None:
logger.warning(
"Registre des rôles du Hub indisponible (%s) — "
"comportement actuel conservé", url)
return None
with _lock:
_cache[sub] = role
_cache_at = time.monotonic()
return role
def reinitialiser_cache() -> None:
"""Vide le cache des rôles (tests, redémarrage à chaud)."""
global _cache_at
with _lock:
_cache.clear()
_cache_at = None
+48 -8
View File
@@ -5,7 +5,7 @@ import os
import re
import threading
from collections.abc import Callable
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -97,6 +97,13 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
Also reads optional configuration:
- VAULT_N_ATTACHMENTS_PATH: relative path to attachments folder
- VAULT_N_SCAN_ATTACHMENTS: "true"/"false" to enable/disable scanning
- DIR_N_READONLY: "true"/"false" — vault DIR déclaré en lecture seule
(les mutations sont refusées en 403, l'UI masque les actions d'écriture)
- DIR_N_IGNORED_DIRS: noms de dossiers ignorés pour ce vault, séparés
par des virgules (toute profondeur, en plus du global IGNORED_DIRS)
- DIR_N_IGNORED_PATHS: motifs fnmatch sur le chemin relatif POSIX dans
le vault, séparés par des virgules (un dossier qui matche cache tout
son sous-arbre) — navigation, lecture et indexation les respectent
Returns:
Dict mapping vault names to configuration dicts with keys:
@@ -104,6 +111,9 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
- attachmentsPath: relative attachments folder (optional)
- scanAttachmentsOnStartup: boolean (default True)
- type: "VAULT" or "DIR"
- readonly: boolean (DIR only, default False)
- ignoredDirs: set of directory names (DIR only, default empty)
- ignoredPaths: list of fnmatch patterns (DIR only, default empty)
"""
vaults: dict[str, dict[str, Any]] = {}
n = 1
@@ -131,12 +141,23 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
path = os.environ.get(f"DIR_{n}_PATH")
if not name or not path:
break
readonly = os.environ.get(f"DIR_{n}_READONLY", "false").lower() == "true"
ignored_dirs = {
d.strip() for d in os.environ.get(f"DIR_{n}_IGNORED_DIRS", "").split(",") if d.strip()
}
ignored_paths = [
p.strip() for p in os.environ.get(f"DIR_{n}_IGNORED_PATHS", "").split(",") if p.strip()
]
vaults[name] = {
"path": path,
"attachmentsPath": None,
"scanAttachmentsOnStartup": False,
"type": "DIR"
"type": "DIR",
"readonly": readonly,
"ignoredDirs": ignored_dirs,
"ignoredPaths": ignored_paths,
}
n += 1
@@ -509,7 +530,8 @@ def _scan_vault(
Args:
vault_name: Display name of the vault.
vault_path: Absolute filesystem path to the vault root.
vault_cfg: Optional vault configuration dict (unused for indexing, kept for compatibility).
vault_cfg: Optional vault configuration dict — ses exclusions
(``ignoredDirs`` / ``ignoredPaths``) sont appliquées au scan.
previous_files: Optional ``{relative_path: file_info}`` snapshot from a
previous scan used for differential reuse.
@@ -529,6 +551,16 @@ def _scan_vault(
root_resolved = vault_root.resolve(strict=False)
# Exclusions propres au vault (DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS) :
# elles s'ajoutent au global IGNORED_DIRS, sans le modifier. Le cfg est
# celui passé par l'appelant — l'index global ne contient pas encore ce
# vault pendant son premier scan, chemin_exclu() (qui lit l'index) ne
# peut donc pas servir ici.
from backend.services.vaults import chemin_exclu_dans_config
cfg = vault_cfg or {}
ignored_names = IGNORED_DIRS | set(cfg.get("ignoredDirs") or [])
# BUG-032: walk without following symlinks and refuse any symlink that
# escapes the vault root, so external data can never be indexed/exposed.
for dirpath, dirnames, filenames in os.walk(vault_root, followlinks=False):
@@ -537,7 +569,11 @@ def _scan_vault(
# Prune ignored and symlinked directories in place (no recursion).
dirnames[:] = [
d for d in dirnames
if d not in IGNORED_DIRS and not (current_dir / d).is_symlink()
if d not in ignored_names
and not (current_dir / d).is_symlink()
and not chemin_exclu_dans_config(
cfg, str((current_dir / d).relative_to(vault_root)).replace("\\", "/")
)
]
for d in dirnames:
@@ -565,6 +601,10 @@ def _scan_vault(
rel_path_str = str(fpath.relative_to(vault_root)).replace("\\", "/")
# Exclusion propre au vault (motif de chemin, ex. un .env ciblé).
if chemin_exclu_dans_config(cfg, rel_path_str):
continue
ext = fpath.suffix.lower()
# Also match extensionless files named like Dockerfile, Makefile
basename_lower = fpath.name.lower()
@@ -581,7 +621,7 @@ def _scan_vault(
try:
relative = fpath.relative_to(vault_root)
stat = fpath.stat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
# #86 differential scan: reuse the previous entry when neither
# size nor mtime changed — skips the disk read + parse below.
@@ -866,7 +906,7 @@ async def build_index(progress_callback=None) -> None:
# Record timestamp of full index rebuild
global _last_full_index_ts
_last_full_index_ts = datetime.now(timezone.utc).isoformat()
_last_full_index_ts = datetime.now(UTC).isoformat()
# Build attachment index
from backend.attachment_indexer import build_attachment_index
@@ -1033,7 +1073,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
return None
stat = fpath.stat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat()
modified = datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat()
# PDF handling — binary, must go through pdf_reader (same as _scan_vault)
tags: list[str] = []
+134 -3
View File
@@ -186,15 +186,31 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
# avec un nonce frais (`backend.csp`) ou servis par 'self'/CDN
# listés s'exécutent. `style-src` garde 'unsafe-inline' (attributs
# `style=` et `el.style` omniprésents — chantier séparé).
# #223 : l'origine du service d'agents n'est pas figée — elle
# est dérivée de la configuration (DRACO_AGENT_URL, sinon
# l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel).
from backend.draco_config import agent_origine, origine, urls_publiques
agent = agent_origine()
# Fusion #69 : le panneau Alertes unifiées lit les API
# notifications des applications soeurs depuis cet hôte ;
# leurs origines rejoignent connect-src (même résolution
# draco_config que les liens de la barre : explicite >
# dérivation depuis l'hôte de l'agent > défaut officiel).
_soeurs69 = " ".join(sorted({
origine(v)
for k, v in urls_publiques().items()
if k in ("obsigate", "flowdeck", "tiktak", "kanbante")
} - {""}))
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://agent.dracodev.net; "
f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent} {_soeurs69}; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"worker-src 'self' blob:; "
"frame-src 'self' blob:; "
f"frame-src 'self' blob: {agent}; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
@@ -515,6 +531,15 @@ app.add_middleware(
allow_headers=["*"],
)
# Recherche partout (#68) : CORS bornée pour GET /api/search — ajoutée
# après le CORSMiddleware global donc exécutée avant lui (dernier
# ajouté = le plus externe) : les preflights OPTIONS sont répondus
# ici, et les réponses de /api/search reçoivent les en-têtes sœurs.
# La politique same-origin explicite du middleware global reste la
# règle par défaut pour tout le reste.
from backend.recherche_cors import RechercheCorsMiddleware
app.add_middleware(RechercheCorsMiddleware)
# Auth router
# Multi-format export (HTML / MD bundle / ePub) — voir backend.routers.files_media (#85 T6c).
from backend.ai_routes import router as ai_router
@@ -524,6 +549,8 @@ from backend.auth.middleware import (
from backend.auth.router import router as auth_router
from backend.bookslm_routes import router as bookslm_router
from backend.routers.backups import router as backups_router
from backend.routers.coffres import router as coffres_router
from backend.routers.comptes_machine import router as comptes_machine_router
from backend.routers.config import _load_config
from backend.routers.config import router as config_router
from backend.routers.conflicts import router as conflicts_router
@@ -558,6 +585,8 @@ app.include_router(search_router) # ROADMAP #85 T5 — Search
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
app.include_router(config_router) # ROADMAP #85 T7 — Config
app.include_router(coffres_router) # Coffre Secrets Phase 1b — JAMAIS dans vaults.py
app.include_router(comptes_machine_router) # HUB-13 (fusion #127) — Admin des comptes, machine-à-machine
app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
@@ -829,6 +858,36 @@ def _html_with_nonce(request: Request, name: str) -> str:
request.state.csp_nonce,
)
def _index_html(request: Request) -> str:
"""``index.html`` + injection des URL publiques de l'écosystème (#223).
Contrat d'injection de dracodev-commons : l'hôte (ObsiGate, qui
sert la page) fournit les URL aux assets partagés — panneau Spark,
barre d'apps, liens inter-apps lisent ``window.DRACO_URLS`` et ne
portent que les défauts officiels en repli. Les valeurs viennent
de :mod:`backend.draco_config` (environnement ; défauts officiels,
noms en ``-dev`` déclarés par le seul ``.env`` du poste). Le script
injecté passe par le même mécanisme de nonce CSP que les autres
scripts inline de la page.
"""
import json as _json
from backend.csp import inject_csp_nonce
from backend.draco_config import urls_publiques
html = (FRONTEND_DIR / "index.html").read_text(encoding="utf-8")
script = (
"<script>window.DRACO_URLS = "
+ _json.dumps(urls_publiques()).replace("<", "\\u003c")
+ ";</script>"
)
if "</head>" in html:
html = html.replace("</head>", script + "</head>", 1)
else: # repli défensif : tout en tête de document
html = script + html
return inject_csp_nonce(html, request.state.csp_nonce)
if FRONTEND_DIR.exists():
# ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache).
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
@@ -902,10 +961,82 @@ 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, jetons {{URL_SHAARIT}} et {{URL_AGENT}} inclus pour
les onglets ShaarIt (barre v0.2.0) et Console
Spark de la barre re-vendangée) et le nonce CSP frais ; la
réponse pose sa propre CSP (préservée par
SecurityHeadersMiddleware, qui ne la pose que si elle est
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_SHAARIT}}", "shaarit"),
("{{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",
"shaarit", "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."""
index_file = FRONTEND_DIR / "index.html"
if index_file.exists():
return HTMLResponse(content=_html_with_nonce(request, "index.html"), headers={"Cache-Control": "no-cache"})
return HTMLResponse(content=_index_html(request), headers={"Cache-Control": "no-cache"})
raise HTTPException(status_code=404, detail="Frontend not found")
+4 -4
View File
@@ -25,7 +25,7 @@ import os
import smtplib
import threading
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from email.message import EmailMessage
from pathlib import Path
from typing import Any
@@ -182,7 +182,7 @@ def create_channel(name: str, channel_type: str, config: dict[str, Any]) -> dict
"type": channel_type,
"enabled": True,
"config": normalized,
"created_at": datetime.now(timezone.utc).isoformat(),
"created_at": datetime.now(UTC).isoformat(),
"last_sent_at": None,
"last_error": None,
}
@@ -287,7 +287,7 @@ def _send_webhook(url: str, title: str, message: str, trigger: str) -> None:
"event": trigger,
"title": title,
"message": message,
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"source": "obsigate-notify",
},
timeout=10.0,
@@ -345,6 +345,6 @@ def _mark_sent(channel_id: str, error: str | None) -> None:
channels = _read_channels()
for channel in channels:
if channel.get("id") == channel_id:
channel["last_sent_at"] = datetime.now(timezone.utc).isoformat()
channel["last_sent_at"] = datetime.now(UTC).isoformat()
channel["last_error"] = error
_write_channels(channels)
+1
View File
@@ -41,6 +41,7 @@ TAGS_METADATA: list[dict[str, str]] = [
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
{"name": "Admin", "description": "Admin-only system monitoring: stats, audit log, backup stats and live stream."},
{"name": "Coffres Secrets", "description": "Secret vaults: encrypted coffres, secrets, consumers, grants and distribution — metadata only, values are never exposed."},
{"name": "Plugins", "description": "Install, enable and manage user plugins."},
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
+2 -2
View File
@@ -6,7 +6,7 @@ import base64
import json
import logging
import os
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -185,7 +185,7 @@ async def subscribe_push(
"keys": request.subscription.keys,
"vault": request.vault,
"username": username,
"created_at": datetime.now(timezone.utc).isoformat()
"created_at": datetime.now(UTC).isoformat()
}
_push_subscriptions.append(subscription)
+204
View File
@@ -0,0 +1,204 @@
"""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",
"https://shaarit.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",
"https://shaarit-dev.dracodev.net",
})
AGENT_ORIGINS = frozenset({
"https://agent.dracodev.net",
"https://agent-dev.dracodev.net",
})
# Clés de draco_config.urls_publiques() qui sont des applications sœurs.
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante", "shaarit")
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)
+39 -7
View File
@@ -16,13 +16,18 @@ sortedcontainers>=2.4.0
snowballstemmer>=2.2.0
weasyprint>=70.0
httpx>=0.27.0
# Plancher de sécurité (BUG-093) : 6.16.0 est vulnérable à deux DoS de
# ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm, fix 6.16.1),
# atteignables via backend/pdf_reader.py (PDF fournis par l'utilisateur).
# Le plancher doit être >= 6.16.1 : l'image Act du runner embarque 6.16.0
# dans sa toolcache Python, donc un plancher trop bas est « already satisfied »
# et n'est jamais mis à niveau.
pypdf>=6.16.1
# Plancher de sécurité (BUG-093, relevé par #224) : 6.16.0 est vulnérable à
# deux DoS de ressources (PYSEC-2026-3910 outlines, PYSEC-2026-3911 XForm,
# fix 6.16.1), atteignables via backend/pdf_reader.py (PDF fournis par
# l'utilisateur). #224 : la version préinstallée dans la toolcache du
# runner (6.19.0) est frappée par 11 CVE supplémentaires (dont
# CVE-2026-113786, CVE-2026-115572, CVE-2026-115585, CVE-2026-115580),
# corrigées au plus tard en 6.21.2. 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.4
pyotp>=2.10.0
segno>=1.5.0
webauthn==2.6.0
@@ -55,3 +60,30 @@ multidict>=6.9.1
# transitive (httpx/requests) — sans plancher explicite, pip-audit
# signale la version héritée de l'environnement.
certifi>=2026.10.19
# Planchers de sécurité (#224) : paquets transitifs ou préinstallés dans
# la toolcache du runner, audités par pip-audit (CI, bloquant) mais jamais
# alignés sans plancher explicite — « already satisfied » (même piège que
# BUG-093). Versions relevées par le job security du run sur 2.78.0 :
# brotli 1.2.0 (transitive fonttools[woff] via weasyprint) — PYSEC-2026-4322
# et GHSA-9v8h-qqgm-4m35, corrigées en 1.2.1.
brotli>=1.2.1
# filelock 3.32.3 (transitive de CacheControl, outil pip-audit, et
# préinstallée dans la toolcache) — CVE-2026-112940, CVE-2026-112913 et
# CVE-2026-112907, corrigées en 4.2.1.
filelock>=4.2.1
# opentelemetry-api 1.37.0 (préinstallée dans la toolcache du runner,
# aucune dépendance directe d'ObsiGate) — CVE-2026-114603, corrigée
# en 1.45.1.
opentelemetry-api>=1.45.1
# Coffre Secrets (Phase 1a) : chiffrement par enveloppe AES-256-GCM.
cryptography>=44.0
# Plancher de securite : msgpack est transitif, version 1.2.1 relevee par le job
# security (pip-audit, bloquant) — CVE-2026-114613, corrigee en 1.2.3.
msgpack>=1.2.3
# 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
+2 -2
View File
@@ -19,7 +19,7 @@ Adaptations strictement équivalentes :
import logging
import os
import time
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -207,7 +207,7 @@ async def api_backups_list(
"file": rel_file,
"backup_file": fpath.name,
"timestamp": ts,
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
"datetime": datetime.fromtimestamp(ts, tz=UTC).isoformat(),
"size": fsize,
"full_path": str(fpath),
})
+828
View File
@@ -0,0 +1,828 @@
"""API du coffre Secrets ObsiGate (Phase 1b, spec §7, §10, §11).
Monté dans ``backend/main.py`` à côté de ``config_router`` — JAMAIS dans
``backend/routers/vaults.py`` : un coffre n'est pas un vault de notes, il
ne passe ni par l'indexeur, ni par la recherche, ni par le graphe, et
aucun outil MCP de secret n'existe.
Règles d'or, appliquées côté serveur à chaque requête :
- Toutes les lectures renvoient des métadonnées seules (``has_value``,
version, dates, octrois actifs) : jamais de valeur, jamais de texte
chiffré, jamais de nonce.
- Seul ``POST /api/coffres/distribuer`` rend une valeur, et uniquement à
un consommateur authentifié par jeton (hash + jti, jamais la session
utilisateur), avec un octroi actif et un contexte obligatoire.
- 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
ne donne AUCUN droit sur un coffre (``check_vault_access`` n'est pas
utilisé ici, volontairement).
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
qu'il n'est pas activé, toutes les routes répondent 503.
- Phase 2 (issues #20) : migration ``api_keys.json`` -> coffre
d'instance, en deux temps (import, puis purge confirmée), et
enregistrement d'un consommateur avec ses octrois en un appel.
- Phase 3 (issue #21) : ``/distribuer`` accepte le contexte Spark à
plat ou imbriqué (``contexte: {type: "mission", id}``) ; les codes
d'erreur restent ceux de la Phase 1b (voir
``docs/COFFRE-contrat-distribution.md`` pour la correspondance avec
l'état ``acces_requis`` côté Spark).
"""
from __future__ import annotations
import logging
import os
from fastapi import APIRouter, Body, Depends, HTTPException, Request
from backend.audit import get_recent_entries, log_coffre_event
from backend.auth.middleware import require_admin, require_auth
from backend.coffre_crypto import (
CryptoIndisponibleError,
KEKAbsenteError,
generate_kek_b64,
)
from backend.coffre_migration import (
COFFRE_INTERNE_SLUG,
creer_consommateur_avec_octrois,
importer_api_keys,
lister_candidats,
purger_api_keys,
)
from backend.coffre_store import (
CoffreExisteError,
IntrouvableError,
SecretExisteError,
SecretExpireError,
ValidationError,
compter_octrois_actifs,
compter_secrets,
create_coffre,
create_consommateur,
create_octroi,
create_secret,
delete_secret,
distribuer_valeur,
est_expire,
get_coffre,
get_coffre_by_id,
get_coffre_or_404,
get_octroi,
get_secret,
list_coffres,
list_consommateurs,
list_octrois,
list_secrets,
revoquer_consommateur,
revoquer_octroi,
rotate_dek,
rotate_kek,
rotate_secret,
secret_metadata,
secrets_accessibles_utilisateur,
statut_octroi_consommateur,
toucher_utilisation,
verifier_jeton_consommateur,
)
logger = logging.getLogger("obsigate.coffres")
router = APIRouter(tags=["Coffres Secrets"])
CODES_ERREUR = {
"octroi_absent",
"octroi_revoque",
"octroi_expire",
"secret_expire",
"kek_absente",
"contexte_manquant",
"consommateur_invalide",
"consommateur_revoque",
"crypto_indisponible",
"secrets_desactives",
}
def secrets_actives() -> bool:
"""Feature flag : défaut désactivé, activation explicite par l'opérateur."""
return os.environ.get("OBSIGATE_SECRETS_ENABLED", "false").lower() in (
"1",
"true",
"yes",
"on",
)
def _exiger_actives() -> None:
if not secrets_actives():
raise HTTPException(status_code=503, detail="secrets_desactives")
def _username(user: dict) -> str:
return str(user.get("username", ""))
def _est_admin(user: dict) -> bool:
return user.get("role") == "admin"
def _peut_voir_coffre(user: dict, coffre: dict) -> bool:
"""Voir les métadonnées d'un coffre."""
if coffre["proprietaire"] == _username(user):
return True
if _est_admin(user) and coffre["perimetre"] == "instance":
return True
# Octroi utilisateur (coffre-wide ou par secret) : accès en lecture.
accessibles = secrets_accessibles_utilisateur(_username(user), coffre)
if accessibles is None:
return True
return len(accessibles) > 0
def _peut_gerer_coffre(user: dict, coffre: dict) -> bool:
"""Créer / roter / supprimer des secrets et gérer les octrois."""
if coffre["proprietaire"] == _username(user):
return True
return _est_admin(user) and coffre["perimetre"] == "instance"
def _exiger_voir(user: dict, coffre: dict) -> None:
if not _peut_voir_coffre(user, coffre):
raise HTTPException(status_code=403, detail="Accès refusé à ce coffre")
def _exiger_gerer(user: dict, coffre: dict) -> None:
if not _peut_gerer_coffre(user, coffre):
raise HTTPException(status_code=403, detail="Accès refusé à ce coffre")
def _coffre_or_404(slug: str) -> dict:
try:
return get_coffre_or_404(slug)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
def _secret_or_404(coffre: dict, nom: str) -> dict:
try:
secret = get_secret(coffre["id"], nom)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if secret is None:
raise HTTPException(status_code=404, detail=f"Secret introuvable : {nom}")
return secret
def _metadata_coffre(coffre: dict) -> dict:
return {
"slug": coffre["slug"],
"nom": coffre["nom"],
"description": coffre.get("description", ""),
"proprietaire": coffre["proprietaire"],
"perimetre": coffre["perimetre"],
"statut": coffre["statut"],
"kek_configuree": coffre.get("dek_chiffree") is not None,
"nb_secrets": compter_secrets(coffre["id"]),
"cree_le": coffre["cree_le"],
}
def _metadata_secret(coffre: dict, secret: dict) -> dict:
return secret_metadata(secret, compter_octrois_actifs(secret["id"]))
def _secrets_visibles(user: dict, coffre: dict) -> list:
"""Secrets visibles par l'utilisateur (métadonnées seules)."""
if coffre["proprietaire"] == _username(user) or (
_est_admin(user) and coffre["perimetre"] == "instance"
):
return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])]
accessibles = secrets_accessibles_utilisateur(_username(user), coffre)
if accessibles is None:
return [_metadata_secret(coffre, s) for s in list_secrets(coffre["id"])]
return [
_metadata_secret(coffre, s)
for s in list_secrets(coffre["id"])
if s["id"] in accessibles
]
def _audit(user: str, action: str, **kwargs) -> None:
try:
log_coffre_event(user, action, **kwargs)
except Exception as exc: # l'audit ne doit jamais casser l'opération
logger.warning("Échec audit coffre (%s) : %s", action, exc)
# ---------------------------------------------------------------------------
# Coffres
# ---------------------------------------------------------------------------
@router.get("/api/coffres")
async def api_list_coffres(current_user=Depends(require_auth)):
_exiger_actives()
visibles = [c for c in list_coffres() if _peut_voir_coffre(current_user, c)]
return [_metadata_coffre(c) for c in visibles]
@router.post("/api/coffres")
async def api_create_coffre(body: dict = Body(...), current_user=Depends(require_auth)):
_exiger_actives()
slug = body.get("slug", "")
perimetre = body.get("perimetre", "personnel")
if perimetre == "instance" and not _est_admin(current_user):
raise HTTPException(
status_code=403, detail="Seul un admin peut créer un coffre d'instance"
)
try:
coffre = create_coffre(
slug=slug,
nom=body.get("nom", ""),
proprietaire=_username(current_user),
perimetre=perimetre,
description=body.get("description", ""),
)
except CoffreExisteError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"coffre_cree",
coffre=coffre["slug"],
resultat="ok",
)
return _metadata_coffre(coffre)
# ---------------------------------------------------------------------------
# Consommateurs (admin seulement, jeton rendu une seule fois)
# ---------------------------------------------------------------------------
@router.get("/api/coffres/consommateurs")
async def api_list_consommateurs(current_user=Depends(require_admin)):
_exiger_actives()
return list_consommateurs()
@router.post("/api/coffres/consommateurs")
async def api_create_consommateur(
body: dict = Body(...), current_user=Depends(require_admin)
):
_exiger_actives()
try:
consommateur, jeton = create_consommateur(
nom=body.get("nom", ""), cree_par=_username(current_user)
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"consommateur_cree",
consommateur=consommateur["nom"],
resultat="ok",
)
# Le jeton n'est rendu qu'ici, jamais stocké en clair, jamais relisté.
return {**consommateur, "jeton": jeton}
@router.post("/api/coffres/consommateurs/avec-octrois")
async def api_create_consommateur_avec_octrois(
body: dict = Body(...), current_user=Depends(require_admin)
):
"""Phase 4 : enregistrer une app = un consommateur + ses octrois.
Le coffre n'est jamais modifié par cette opération. Le jeton n'est
rendu qu'ici, jamais stocké en clair, jamais relisté. Un octroi en
erreur (coffre ou secret introuvable) est signalé dans ``erreurs``
sans annuler le consommateur ni les autres octrois.
"""
_exiger_actives()
try:
consommateur, jeton, octrois, erreurs = creer_consommateur_avec_octrois(
nom=body.get("nom", ""),
octrois_demandes=body.get("octrois") or [],
cree_par=_username(current_user),
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"consommateur_cree",
consommateur=consommateur["nom"],
resultat="ok",
)
for octroi in octrois:
coffre_octroi = get_coffre_by_id(octroi["coffre_id"])
_audit(
_username(current_user),
"octroi_cree",
coffre=coffre_octroi["slug"] if coffre_octroi else None,
resultat="ok",
)
return {**consommateur, "jeton": jeton, "octrois": octrois, "erreurs": erreurs}
@router.delete("/api/coffres/consommateurs/{consommateur_id}")
async def api_revoke_consommateur(
consommateur_id: str, current_user=Depends(require_admin)
):
_exiger_actives()
try:
consommateur = revoquer_consommateur(consommateur_id)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_audit(
_username(current_user),
"consommateur_revoque",
consommateur=consommateur["nom"],
resultat="ok",
)
return consommateur
# ---------------------------------------------------------------------------
# Migration Phase 2 (admin seulement) : api_keys.json -> coffre d'instance.
# L'import ne supprime rien ; la purge exige une confirmation explicite
# et ne supprime que les noms listés. Aucune réponse ne contient jamais
# une valeur, seulement des noms et des comptes.
# Ces routes sont déclarées AVANT /api/coffres/{slug} pour ne pas être
# capturées par le paramètre slug.
# ---------------------------------------------------------------------------
@router.get("/api/coffres/migration/api-keys")
async def api_migration_apercu(current_user=Depends(require_admin)):
_exiger_actives()
return {"coffre": COFFRE_INTERNE_SLUG, "candidats": lister_candidats()}
@router.post("/api/coffres/migration/api-keys/import")
async def api_migration_import(current_user=Depends(require_admin)):
_exiger_actives()
try:
resultat = importer_api_keys(cree_par=_username(current_user))
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
for nom in resultat["migres"]:
_audit(
_username(current_user),
"migration_api_keys",
coffre=COFFRE_INTERNE_SLUG,
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
resultat="ok",
)
return resultat
@router.post("/api/coffres/migration/api-keys/purger")
async def api_migration_purger(
body: dict = Body(...), current_user=Depends(require_admin)
):
_exiger_actives()
if body.get("confirmer") is not True:
raise HTTPException(status_code=400, detail="confirmation_requise")
try:
resultat = purger_api_keys(
noms=body.get("noms") or [], confirmer=True
)
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
for nom in resultat["supprimes"]:
_audit(
_username(current_user),
"migration_api_keys_purge",
coffre=COFFRE_INTERNE_SLUG,
secret_ref=f"coffre:{COFFRE_INTERNE_SLUG}/{nom}",
resultat="ok",
)
return resultat
# ---------------------------------------------------------------------------
# Distribution (serveur-à-serveur, hors session utilisateur, hors LLM)
# ---------------------------------------------------------------------------
@router.post("/api/coffres/distribuer")
async def api_distribuer(request: Request, body: dict = Body(...)):
_exiger_actives()
reference = (body.get("reference") or "").strip()
# Phase 3 (Spark) : le contexte peut arriver à plat
# (contexte_type / contexte_id) ou imbriqué (contexte: {type, id},
# forme envoyée par le client Spark). Les deux formes sont acceptées.
contexte_type = str(body.get("contexte_type") or "").strip()
contexte_id_raw = body.get("contexte_id")
contexte_id = "" if contexte_id_raw is None else str(contexte_id_raw).strip()
if not contexte_type or not contexte_id:
contexte = body.get("contexte")
if isinstance(contexte, dict):
contexte_type = contexte_type or str(contexte.get("type") or "").strip()
contexte_id_raw = contexte.get("id")
contexte_id = contexte_id or (
"" if contexte_id_raw is None else str(contexte_id_raw).strip()
)
if not contexte_type or not contexte_id:
raise HTTPException(status_code=400, detail="contexte_manquant")
if not reference.startswith("coffre:") or "/" not in reference[7:]:
raise HTTPException(status_code=400, detail="reference_invalide")
slug, nom = reference[7:].split("/", 1)
# Authentification consommateur : Bearer, hash + statut, jamais un JWT
# de session utilisateur.
auth = request.headers.get("Authorization", "")
jeton = auth[7:] if auth.startswith("Bearer ") else ""
consommateur = verifier_jeton_consommateur(jeton)
if consommateur is None:
_audit("consommateur", "distribution_refusee", secret_ref=reference,
resultat="consommateur_invalide", contexte_id=contexte_id)
raise HTTPException(status_code=401, detail="consommateur_invalide")
if consommateur["statut"] != "actif":
_audit("consommateur", "distribution_refusee", secret_ref=reference,
consommateur=consommateur["nom"],
resultat="consommateur_revoque", contexte_id=contexte_id)
raise HTTPException(status_code=403, detail="consommateur_revoque")
try:
coffre = get_coffre(slug)
secret = get_secret(coffre["id"], nom) if coffre else None
except ValidationError:
raise HTTPException(status_code=400, detail="reference_invalide") from None
if coffre is None or secret is None or coffre["statut"] != "actif":
# Anti-énumération : un secret inexistant se comporte comme un
# octroi absent, il ne révèle pas l'existence du coffre/secret.
_audit("consommateur", "distribution_refusee", secret_ref=reference,
consommateur=consommateur["nom"],
resultat="octroi_absent", contexte_id=contexte_id)
raise HTTPException(status_code=403, detail="octroi_absent")
statut, _octroi = statut_octroi_consommateur(
consommateur["id"], coffre["id"], secret["id"]
)
if statut != "ok":
code = {
"revoque": "octroi_revoque",
"expire": "octroi_expire",
"absent": "octroi_absent",
}[statut]
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat=code, contexte_id=contexte_id)
raise HTTPException(status_code=403, detail=code)
try:
valeur = distribuer_valeur(coffre, secret)
except SecretExpireError:
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat="secret_expire", contexte_id=contexte_id)
raise HTTPException(status_code=410, detail="secret_expire") from None
except KEKAbsenteError:
_audit("consommateur", "distribution_refusee", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat="kek_absente", contexte_id=contexte_id)
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
toucher_utilisation(secret["id"])
_audit("consommateur", "distribution_ok", coffre=coffre["slug"],
secret_ref=reference, consommateur=consommateur["nom"],
resultat="ok", contexte_id=contexte_id)
return {
"reference": reference,
"valeur": valeur,
"version": secret["version_courante"],
}
@router.get("/api/coffres/distribuables")
async def api_distribuables(request: Request):
"""Secrets distribuables pour un consommateur (issue #43).
Authentification par jeton consommateur (la même que
/distribuer), jamais par session utilisateur : c'est le
serveur agent qui appelle, pour alimenter la liste déroulante
de ses fournisseurs LLM. Retour = métadonnées seules des
secrets pour lesquels ce consommateur a un octroi ACTIF
(coffre actif, secret non expiré, octroi ni révoqué ni
expiré, y compris les octrois coffre-wide). Un consommateur
sans octroi reçoit une liste vide — jamais d'erreur
révélatrice, jamais de valeur.
"""
_exiger_actives()
auth = request.headers.get("Authorization", "")
jeton = auth[7:] if auth.startswith("Bearer ") else ""
consommateur = verifier_jeton_consommateur(jeton)
if consommateur is None:
raise HTTPException(status_code=401,
detail="consommateur_invalide")
if consommateur["statut"] != "actif":
raise HTTPException(status_code=403,
detail="consommateur_revoque")
sortie = []
for coffre in list_coffres():
if coffre["statut"] != "actif":
continue
for secret in list_secrets(coffre["id"]):
if est_expire(secret.get("expiration")):
continue
statut, _octroi = statut_octroi_consommateur(
consommateur["id"], coffre["id"], secret["id"])
if statut != "ok":
continue
sortie.append({
"reference": f"coffre:{coffre['slug']}/{secret['nom']}",
"coffre": coffre["slug"],
"nom": secret["nom"],
"version": secret["version_courante"],
})
return sortie
# ---------------------------------------------------------------------------
# Un coffre, ses secrets, ses octrois, son journal
# ---------------------------------------------------------------------------
@router.get("/api/coffres/{slug}")
async def api_get_coffre(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
return _metadata_coffre(coffre)
@router.get("/api/coffres/{slug}/secrets")
async def api_list_secrets(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
return _secrets_visibles(current_user, coffre)
@router.post("/api/coffres/{slug}/secrets")
async def api_create_secret(
slug: str, body: dict = Body(...), current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
secret = create_secret(
coffre=coffre,
nom=body.get("nom", ""),
type_secret=body.get("type", "autre"),
valeur=body.get("valeur", ""),
service=body.get("service", ""),
expiration=body.get("expiration"),
note=body.get("note", ""),
)
except SecretExisteError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
_audit(
_username(current_user),
"secret_cree",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="ok",
)
return _metadata_secret(coffre, secret)
@router.post("/api/coffres/{slug}/secrets/{nom}/rotation")
async def api_rotate_secret(
slug: str, nom: str, body: dict = Body(...), current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
secret = rotate_secret(coffre, nom, body.get("valeur", ""))
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
_audit(
_username(current_user),
"secret_rotation",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="ok",
)
return _metadata_secret(coffre, secret)
@router.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)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
secret, _octrois = delete_secret(coffre, nom)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_audit(
_username(current_user),
"secret_supprime",
coffre=coffre["slug"],
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
resultat="ok",
)
return {"status": "deleted", "nom": secret["nom"]}
@router.get("/api/coffres/{slug}/octrois")
async def api_list_octrois(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
return list_octrois(coffre["id"])
@router.post("/api/coffres/{slug}/octrois")
async def api_create_octroi(
slug: str, body: dict = Body(...), current_user=Depends(require_auth)
):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
secret_id = None
if body.get("secret_nom"):
secret = _secret_or_404(coffre, body["secret_nom"])
secret_id = secret["id"]
try:
octroi = create_octroi(
coffre=coffre,
cible_type=body.get("cible_type", ""),
cible_id=body.get("cible_id", ""),
secret_id=secret_id,
cree_par=_username(current_user),
expiration=body.get("expiration"),
)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValidationError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(
_username(current_user),
"octroi_cree",
coffre=coffre["slug"],
secret_ref=(
f"coffre:{coffre['slug']}/{body.get('secret_nom', '*')}"
),
resultat="ok",
)
return octroi
@router.delete("/api/coffres/octrois/{octroi_id}")
async def api_revoke_octroi(octroi_id: str, current_user=Depends(require_auth)):
_exiger_actives()
# Le gestionnaire du coffre concerné (ou l'admin d'instance) seul peut
# révoquer : on résout le coffre depuis l'octroi avant toute action.
octroi = get_octroi(octroi_id)
if octroi is None:
raise HTTPException(status_code=404, detail="Octroi introuvable")
coffre = get_coffre_by_id(octroi["coffre_id"])
if coffre is None:
raise HTTPException(status_code=404, detail="Coffre introuvable")
_exiger_gerer(current_user, coffre)
try:
octroi = revoquer_octroi(octroi_id)
except IntrouvableError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
_audit(
_username(current_user),
"octroi_revoque",
coffre=coffre["slug"],
resultat="ok",
)
return octroi
@router.get("/api/coffres/{slug}/journal")
async def api_journal(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_voir(current_user, coffre)
# Défense en profondeur : même si une entrée contenait un jour un champ
# sensible, il ne sort jamais par cette route.
nettoyees = []
for entry in get_recent_entries(limit=100):
if entry.get("coffre") != slug:
continue
nettoyees.append(
{
k: v
for k, v in entry.items()
if k not in ("valeur", "ciphertext", "nonce", "jeton", "dek_chiffree")
}
)
return nettoyees
# ---------------------------------------------------------------------------
# Administration KEK / rotations (admin seulement)
# ---------------------------------------------------------------------------
@router.post("/api/coffres/admin/generer-kek")
async def api_generer_kek(current_user=Depends(require_admin)):
_exiger_actives()
# La KEK générée n'est rendue qu'une fois, ici, et jamais stockée par
# ObsiGate : l'opérateur doit la sauvegarder hors dépôt immédiatement.
return {"kek": generate_kek_b64()}
@router.post("/api/coffres/{slug}/rotation-dek")
async def api_rotate_dek(slug: str, current_user=Depends(require_auth)):
_exiger_actives()
coffre = _coffre_or_404(slug)
_exiger_gerer(current_user, coffre)
try:
coffre = rotate_dek(slug)
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except CryptoIndisponibleError:
raise HTTPException(status_code=503, detail="crypto_indisponible") from None
_audit(_username(current_user), "dek_rotation", coffre=coffre["slug"],
resultat="ok")
return _metadata_coffre(coffre)
@router.post("/api/coffres/admin/rotation-kek")
async def api_rotate_kek(
body: dict = Body(...), current_user=Depends(require_admin)
):
_exiger_actives()
try:
count = rotate_kek(body.get("nouvelle_kek", ""))
except KEKAbsenteError:
raise HTTPException(status_code=503, detail="kek_absente") from None
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_audit(_username(current_user), "kek_rotation", resultat="ok")
return {"status": "ok", "coffres_rechiffres": count}
+424
View File
@@ -0,0 +1,424 @@
# backend/routers/comptes_machine.py
# HUB-13 (fusion #127) — API d'administration des comptes, machine-à-machine.
#
# Le Hub (interface d'administration des utilisateurs) administre les
# comptes dont ObsiGate reste le magasin (décisions D1(a)/D2(a) du plan
# Hub v2) via ces routes, en lecture et en écriture : lister, lire le
# profil étendu, créer, modifier, désactiver/réactiver, supprimer,
# réinitialiser le mot de passe et le MFA.
#
# HUB-22 (#227, vague C du plan Hub v2.1) ajoute, sous la même garde et
# avec la même journalisation, les routes « sessions » : lister les
# sessions draco actives d'un compte, en révoquer une (par son `jti`,
# jamais celle d'un autre compte) ou toutes (déconnexion globale, sans
# désactiver le compte), via le registre des émissions de HUB-13.
#
# Emplacement : ``/api/admin/machine/comptes`` — dans l'espace
# d'administration existant (``/api/admin/…``), avec un segment
# ``machine`` explicite qui le distingue des routes d'administration par
# session (``/api/auth/admin/users``, garde ``require_admin``). Aucune
# session utilisateur — même administratrice — n'ouvre ces routes :
# seule la clé machine dédiée les protège.
#
# Authentification machine (même famille que ``GET /api/sso/revocations``) :
# DRACO_ADMIN_MACHINE_KEY clé dédiée à cette API (en-tête
# ``X-Draco-Machine-Key``). Distincte de
# ``DRACO_SSO_MACHINE_KEY`` : le périmètre
# (écriture de comptes) est plus sensible que
# la seule lecture des révocations (plan §3).
# Sans elle, l'API est inerte (404, comme la
# liste de révocations non configurée).
# X-Draco-Actor identité de service appelante, facultative
# (défaut ``service-machine``) — consignée
# comme acteur dans le journal d'audit.
#
# Chaque action est journalisée côté ObsiGate dans le journal d'audit
# existant (``data/audit.log``, voir backend.audit.log_compte_admin).
from __future__ import annotations
import hmac
import logging
import os
import re
import time
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, validator
from backend import draco_session
from backend.audit import log_compte_admin
from backend.auth.password import validate_password_strength
from backend.auth.user_store import (
create_user,
delete_user,
get_all_users,
get_user,
update_user,
)
from backend.services.net import get_client_ip
logger = logging.getLogger("obsigate.comptes_machine")
router = APIRouter(prefix="/api/admin/machine/comptes", tags=["admin"])
#: Variable d'environnement portant la clé machine dédiée à cette API.
MACHINE_KEY_ENV = "DRACO_ADMIN_MACHINE_KEY"
#: En-tête de la clé machine (même nom que pour /api/sso/revocations).
MACHINE_KEY_HEADER = "X-Draco-Machine-Key"
#: En-tête facultatif : identité de service appelante (journal d'audit).
ACTOR_HEADER = "X-Draco-Actor"
#: Acteur consigné quand l'appelant ne se nomme pas.
DEFAULT_ACTOR = "service-machine"
# ── Garde machine ───────────────────────────────────────────────────────
def require_machine_admin(request: Request) -> str:
"""Exige la clé machine dédiée ; retourne l'identité de service.
Jamais de repli sur une session utilisateur : sans en-tête valide,
la réponse est 401 (ou 404 si l'API n'est pas configurée, patron de
``GET /api/sso/revocations``).
"""
machine_key = os.environ.get(MACHINE_KEY_ENV, "")
if not machine_key:
raise HTTPException(404, "Non configuré")
provided = request.headers.get(MACHINE_KEY_HEADER, "")
if not provided or not hmac.compare_digest(provided, machine_key):
raise HTTPException(401, "Clé machine invalide")
actor = (request.headers.get(ACTOR_HEADER, "") or "").strip()
return actor[:64] if actor else DEFAULT_ACTOR
# ── Profil étendu (liste blanche — jamais de secret) ────────────────────
def _profil(user: dict) -> dict:
"""Profil étendu d'un compte, sûr à exposer à un service.
Liste blanche stricte : ni ``password_hash``, ni secret TOTP
(``mfa_secret``/``mfa_secret_pending``), ni codes de récupération,
ni identifiants WebAuthn ne sortent — seul l'état MFA (oui/non)
est exposé. L'avatar suit le modèle existant : la valeur stockée
(data-URL ou ``None``, comme ``GET /api/auth/me``) plus le booléen
de présence ``avatar_present`` pour les listes légères.
"""
return {
"id": user.get("id"),
"username": user.get("username"),
"display_name": user.get("display_name"),
"language": user.get("language", "fr"),
"avatar": user.get("avatar"),
"avatar_present": bool(user.get("avatar")),
"role": user.get("role"),
"active": bool(user.get("active")),
"vaults": user.get("vaults", []),
"mfa_enabled": bool(user.get("mfa_enabled")),
"mfa_method": user.get("mfa_method"),
"must_change_password": bool(user.get("must_change_password")),
"created_at": user.get("created_at"),
"last_login": user.get("last_login"),
}
def _compte_ou_404(username: str) -> dict:
user = get_user(username)
if user is None:
raise HTTPException(404, "Compte introuvable")
return user
def _identifiant_sessions(user: dict) -> str:
"""Clé du registre des émissions draco pour un compte.
C'est le ``sub`` des jetons draco : l'identifiant du compte
(``id``), à défaut son nom d'utilisateur — exactement la clé
qu'utilise ``_revoquer_sessions`` depuis HUB-13.
"""
return str(user.get("id") or user.get("username") or "")
def _revoquer_sessions(user: dict) -> int:
"""Révoque les sessions d'un compte ; retourne le nombre de jti draco.
Les ``jti`` du cookie partagé émis pour ce compte sont révoqués via
le mécanisme existant (``draco_session.revoke``, exposé aux autres
applications par ``GET /api/sso/revocations``). Les jetons natifs
sont invalidés par le garde ``password_changed_at`` (BUG-028),
avancé par l'appelant (désactivation, réinitialisation du mot de
passe) ou par le drapeau ``active`` tant que le compte est inactif.
"""
return draco_session.revoke_all_for_user(_identifiant_sessions(user))
def _audit(request: Request, acteur: str, action: str, cible: str,
details: dict | None = None) -> None:
log_compte_admin(acteur, action, cible,
ip=get_client_ip(request), details=details)
# ── Modèles de requête ──────────────────────────────────────────────────
class CreerCompteRequest(BaseModel):
username: str
password: str
display_name: str | None = None
role: str = "user"
vaults: list[str] = []
@validator("password")
def password_valid(cls, v):
return validate_password_strength(v)
@validator("username")
def username_valid(cls, v):
if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v):
raise ValueError("2-32 caractères alphanumériques, _ ou -")
return v.lower()
@validator("role")
def role_valid(cls, v):
if v not in ("admin", "user"):
raise ValueError("Rôle invalide")
return v
class ModifierCompteRequest(BaseModel):
display_name: str | None = None
role: str | None = None
language: str | None = None
vaults: list[str] | None = None
@validator("role")
def role_valid(cls, v):
if v is not None and v not in ("admin", "user"):
raise ValueError("Rôle invalide")
return v
@validator("language")
def language_valid(cls, v):
if v is not None and v not in ("fr", "en"):
raise ValueError("Langue invalide (fr | en)")
return v
class ReinitialiserMotDePasseRequest(BaseModel):
password: str
@validator("password")
def password_valid(cls, v):
return validate_password_strength(v)
# ── Lecture ─────────────────────────────────────────────────────────────
@router.get("")
async def lister_comptes(acteur: str = Depends(require_machine_admin)):
"""Liste tous les comptes (profils étendus, sans aucun secret)."""
comptes = [_profil(u) for u in get_all_users()]
return {"comptes": comptes, "total": len(comptes)}
@router.get("/{username}")
async def lire_compte(username: str,
acteur: str = Depends(require_machine_admin)):
"""Lit un compte et son profil étendu."""
return _profil(_compte_ou_404(username))
# ── Écriture ────────────────────────────────────────────────────────────
@router.post("", status_code=201)
async def creer_compte(req: CreerCompteRequest, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Crée un compte. Le mot de passe initial est temporaire :
``must_change_password`` est posé (changement forcé signalé à la
première connexion, comme après une réinitialisation)."""
if get_user(req.username) is not None:
raise HTTPException(409, "Identifiant déjà utilisé")
try:
user = create_user(
req.username, req.password, req.role, req.vaults,
req.display_name, must_change_password=True,
)
except ValueError as e:
raise HTTPException(409, str(e)) from e
# #194 : dossier personnel — même provisionnement que la création
# par session administratrice (best effort).
from backend.user_home import ensure_user_home
await ensure_user_home(req.username)
fresh = get_user(req.username)
if fresh:
user = fresh
_audit(request, acteur, "compte_cree", req.username,
{"role": req.role})
return _profil(user)
@router.patch("/{username}")
async def modifier_compte(username: str, req: ModifierCompteRequest,
request: Request,
acteur: str = Depends(require_machine_admin)):
"""Modifie un compte (nom affiché, rôle global, langue, vaults)."""
_compte_ou_404(username)
updates = req.dict(exclude_none=True)
if not updates:
raise HTTPException(400, "Aucun champ à modifier")
updated = update_user(username, updates)
_audit(request, acteur, "compte_modifie", username,
{"champs": sorted(updates.keys())})
return _profil(updated)
@router.post("/{username}/desactiver")
async def desactiver_compte(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Désactive un compte : plus aucun nouveau jeton n'est émis
(connexion refusée) et les ``jti`` draco actifs sont révoqués."""
user = _compte_ou_404(username)
updated = update_user(username, {
"active": False,
# BUG-028 : avancer le repère invalide aussi les jetons natifs
# déjà émis — ils ne ressuscitent pas à la réactivation.
"password_changed_at": time.time(),
})
nb_jti = _revoquer_sessions(user)
_audit(request, acteur, "compte_desactive", username,
{"jti_revoques": nb_jti})
return _profil(updated)
@router.post("/{username}/reactiver")
async def reactiver_compte(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Réactive un compte désactivé (les anciennes sessions restent
révoquées : une nouvelle connexion est nécessaire)."""
_compte_ou_404(username)
updated = update_user(username, {"active": True})
_audit(request, acteur, "compte_reactive", username)
return _profil(updated)
@router.delete("/{username}")
async def supprimer_compte(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Supprime un compte et révoque ses ``jti`` draco actifs."""
user = _compte_ou_404(username)
nb_jti = _revoquer_sessions(user)
try:
delete_user(username)
except ValueError as e:
raise HTTPException(404, str(e)) from e
# #194 : fermer le vault du dossier personnel (dossier conservé),
# comme la suppression par session administratrice.
from backend.user_home import release_user_home
await release_user_home(username)
_audit(request, acteur, "compte_supprime", username,
{"jti_revoques": nb_jti})
return {"message": f"Compte '{username}' supprimé"}
@router.post("/{username}/reinitialiser-mot-de-passe")
async def reinitialiser_mot_de_passe(
username: str, req: ReinitialiserMotDePasseRequest,
request: Request, acteur: str = Depends(require_machine_admin)):
"""Pose un nouveau mot de passe temporaire : changement forcé à la
première connexion (``must_change_password``, signalé par la
connexion et ``GET /api/auth/me``, effacé au changement effectif).
Les sessions existantes sont révoquées (repère BUG-028 + ``jti``
draco). Le mot de passe n'est jamais journalisé."""
user = _compte_ou_404(username)
updated = update_user(username, {
"password": req.password,
"must_change_password": True,
})
nb_jti = _revoquer_sessions(user)
_audit(request, acteur, "mot_de_passe_reinitialise", username,
{"jti_revoques": nb_jti})
return _profil(updated)
@router.post("/{username}/reinitialiser-mfa")
async def reinitialiser_mfa(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Efface l'enrôlement TOTP d'un compte (utilisateur verrouillé
hors de son second facteur) : secret, secret en attente et codes
de récupération sont supprimés. Les clés WebAuthn éventuelles sont
conservées et gardent alors le MFA actif. Le secret n'est jamais
exposé — ni avant, ni après."""
user = _compte_ou_404(username)
webauthn_restantes = len(user.get("webauthn_credentials") or [])
updates: dict = {
"mfa_secret": None,
"mfa_secret_pending": None,
"mfa_recovery_codes": [],
}
if webauthn_restantes:
updates["mfa_method"] = "webauthn"
else:
updates["mfa_enabled"] = False
updates["mfa_method"] = None
updated = update_user(username, updates)
_audit(request, acteur, "mfa_reinitialise", username,
{"webauthn_conserves": webauthn_restantes})
return _profil(updated)
# ── Sessions draco (HUB-22) ─────────────────────────────────────────────
@router.get("/{username}/sessions")
async def lister_sessions(username: str,
acteur: str = Depends(require_machine_admin)):
"""Liste les sessions draco actives d'un compte.
Une session par ``jti`` émis et non expiré (registre des émissions
de HUB-13), triées par expiration croissante ; ``revoquee`` signale
un ``jti`` déjà présent dans la liste des révocations et
``expire_le`` est l'échéance ``exp`` en ISO 8601 UTC.
"""
user = _compte_ou_404(username)
sessions = [{
"jti": s["jti"],
"exp": s["exp"],
"expire_le": datetime.fromtimestamp(s["exp"], tz=UTC).isoformat(),
"revoquee": s["revoquee"],
} for s in draco_session.issued_sessions_for_user(
_identifiant_sessions(user))]
return {"username": username, "sessions": sessions}
@router.post("/{username}/sessions/{jti}/revoquer")
async def revoquer_session(username: str, jti: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Révoque une seule session draco d'un compte, par son ``jti``.
404 si le ``jti`` ne figure pas dans l'ensemble actif de ce
compte : une session d'un autre utilisateur n'est jamais révoquée
par cette route (aucune révocation cross-utilisateur).
"""
user = _compte_ou_404(username)
if not draco_session.revoke_one_for_user(
_identifiant_sessions(user), jti):
raise HTTPException(404, "Session introuvable")
_audit(request, acteur, "session_revoquee", username, {"jti": jti})
return {"jti": jti, "revoquee": True}
@router.post("/{username}/sessions/revoquer-toutes")
async def revoquer_toutes_sessions(
username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Révoque toutes les sessions draco actives d'un compte.
C'est la « déconnexion globale » : contrairement à la
désactivation, le compte reste actif — seule l'émission d'une
nouvelle session (nouvelle connexion) rouvre l'accès.
"""
user = _compte_ou_404(username)
nb_jti = _revoquer_sessions(user)
_audit(request, acteur, "sessions_revoquees", username,
{"jti_revoques": nb_jti})
return {"revoquees": nb_jti}
+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 -1
View File
@@ -49,7 +49,7 @@ async def api_events(current_user=Depends(require_auth)):
try:
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
except asyncio.TimeoutError:
except TimeoutError:
# Keepalive comment
yield ": keepalive\n\n"
except asyncio.CancelledError:
+17
View File
@@ -11,6 +11,14 @@
# GET /api/sso/revocations : liste des jti révoqués, réservée aux
# autres applications (clé machine DRACO_SSO_MACHINE_KEY, en-tête
# X-Draco-Machine-Key) qui la mettent en cache 60 s (design §5.2).
#
# GET /sso/logout?return_to=<url> : symétrique de /sso/authorize —
# point d'entrée navigable de la déconnexion globale (BUG-122,
# fusion #141). Les autres applications y naviguent depuis leur menu
# compte ; la déconnexion exécutée est celle de POST /api/auth/logout
# (logique partagée : jetons natifs révoqués, jti draco révoqué,
# cookie effacé sur le domaine parent), puis redirection vers le
# return_to validé (même liste blanche ; repli « / »).
from __future__ import annotations
@@ -21,6 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from backend import draco_session
from backend.auth.router import apply_global_logout
router = APIRouter(tags=["SSO"])
@@ -34,6 +43,14 @@ async def sso_authorize(return_to: str = ""):
return RedirectResponse(url="/", status_code=302)
@router.get("/sso/logout")
async def sso_logout(request: Request, return_to: str = ""):
target = draco_session.validate_return_to(return_to)
response = RedirectResponse(url=target or "/", status_code=302)
apply_global_logout(request, response)
return response
@router.get("/api/sso/revocations")
async def sso_revocations(request: Request):
machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "")
+12 -12
View File
@@ -26,7 +26,7 @@ import logging
import os
import threading
import uuid
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
@@ -118,7 +118,7 @@ def _validate_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
try:
parsed = datetime.fromisoformat(at)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
parsed = parsed.replace(tzinfo=UTC)
except ValueError:
raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None
return {"kind": kind, "at": parsed.isoformat()}
@@ -138,7 +138,7 @@ def create_task(
"""Create a scheduled task. Raises ValueError on invalid action/schedule."""
validated_action = _validate_action(action)
validated_schedule = _validate_schedule(schedule)
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
with _lock:
tasks = _read_tasks()
task = {
@@ -200,9 +200,9 @@ def delete_task(task_id: str) -> bool:
def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datetime:
"""Compute the next due datetime for *task*."""
now = now or datetime.now(timezone.utc)
now = now or datetime.now(UTC)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
now = now.replace(tzinfo=UTC)
schedule = task.get("schedule", {})
kind = schedule.get("kind")
last_run_at = task.get("last_run_at")
@@ -211,7 +211,7 @@ def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datet
try:
last = datetime.fromisoformat(str(last_run_at))
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
last = last.replace(tzinfo=UTC)
except ValueError:
last = None
if kind == "interval_hours":
@@ -233,11 +233,11 @@ def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datet
try:
at = datetime.fromisoformat(str(schedule.get("at")))
if at.tzinfo is None:
at = at.replace(tzinfo=timezone.utc)
at = at.replace(tzinfo=UTC)
except ValueError:
return now
if task.get("last_run_at"):
return datetime.max.replace(tzinfo=timezone.utc) # déjà exécutée
return datetime.max.replace(tzinfo=UTC) # déjà exécutée
return at
return now + timedelta(hours=24)
@@ -278,7 +278,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
return {"task_id": task_id, "skipped": True, "reason": "disabled"}
try:
result = _execute_action(task)
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
task["last_run_at"] = datetime.now(UTC).isoformat()
task["last_status"] = "ok"
task["last_error"] = None
task["run_count"] = int(task.get("run_count", 0)) + 1
@@ -292,7 +292,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
broadcast("schedule_success", f"Tâche « {task['name']} » OK", "Exécution manuelle réussie.")
return {"task_id": task_id, "ok": True, "result": result}
except Exception as e:
task["last_run_at"] = datetime.now(timezone.utc).isoformat()
task["last_run_at"] = datetime.now(UTC).isoformat()
task["last_status"] = "error"
task["last_error"] = str(e)
task["run_count"] = int(task.get("run_count", 0)) + 1
@@ -315,7 +315,7 @@ def run_task(task_id: str, *, manual: bool = False) -> dict[str, Any]:
def tick(now: datetime | None = None) -> list[dict[str, Any]]:
"""Run every due task. Returns per-task outcomes (empty when idle)."""
now = now or datetime.now(timezone.utc)
now = now or datetime.now(UTC)
outcomes: list[dict[str, Any]] = []
for task in _read_tasks():
if not task.get("enabled", True):
@@ -323,7 +323,7 @@ def tick(now: datetime | None = None) -> list[dict[str, Any]]:
try:
next_run = datetime.fromisoformat(str(task.get("next_run_at") or ""))
if next_run.tzinfo is None:
next_run = next_run.replace(tzinfo=timezone.utc)
next_run = next_run.replace(tzinfo=UTC)
except ValueError:
next_run = compute_next_run(task, now)
if next_run <= now:
+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):
+3 -3
View File
@@ -14,7 +14,7 @@ import logging
import os
import shutil
import time
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -145,7 +145,7 @@ def list_backup_files(vault_name: str, relative_path: str) -> list[dict[str, Any
except (ValueError, TypeError):
continue
try:
dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
dt = datetime.fromtimestamp(ts, tz=UTC).isoformat()
except (OSError, OverflowError, ValueError) as ts_err:
logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}")
continue
@@ -219,7 +219,7 @@ def diff_backup(
status=500,
details={"vault": vault_name, "path": path, "version": ts},
) from e
dt = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
dt = datetime.fromtimestamp(ts, tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
return content, f"{path}@{dt}"
try:
+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] = []
+1 -1
View File
@@ -117,7 +117,7 @@ def list_recent(
for v_name, f in recent:
iso_modified = f.get("modified", "")
try:
mtime_dt = datetime.fromisoformat(iso_modified.replace("Z", "+00:00"))
mtime_dt = datetime.fromisoformat(iso_modified)
mtime_val = mtime_dt.timestamp()
except Exception:
mtime_val = time.time()
+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:
+76 -5
View File
@@ -7,6 +7,8 @@ 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
@@ -14,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
@@ -28,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
@@ -49,7 +114,7 @@ def get_vault_root(vault_name: str) -> Path:
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
"""Return the direct children of a vault directory (directories first)."""
from datetime import datetime, timezone
from datetime import datetime
from backend.indexer import SUPPORTED_EXTENSIONS
from backend.vault_settings import get_vault_setting
@@ -57,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",
@@ -73,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())
)
@@ -98,7 +166,7 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
"type": "file",
"size": stat.st_size,
"modified": stat.st_mtime,
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
"extension": entry.suffix.lower(),
})
except PermissionError:
@@ -156,7 +224,7 @@ def list_all_files(
ServiceError: ``not_found`` (404) if the directory does not exist,
``permission_denied`` (403) if it cannot be read.
"""
from datetime import datetime, timezone
from datetime import datetime
from backend.indexer import IGNORED_DIRS
@@ -199,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("\\", "/")
@@ -215,7 +286,7 @@ def list_all_files(
"vault": vault_name,
"size": stat.st_size,
"modified": stat.st_mtime,
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"modified_iso": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
"extension": ext.lstrip(".") if ext else "",
"tags": _indexed_tags(vault_name, rel_path),
}
+5 -5
View File
@@ -11,7 +11,7 @@ import json
import logging
import secrets
import threading
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from pathlib import Path
logger = logging.getLogger("obsigate.share")
@@ -58,7 +58,7 @@ def create_share(
expires_at = None
if expires_in_hours:
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_in_hours)).isoformat()
expires_at = (datetime.now(UTC) + timedelta(hours=expires_in_hours)).isoformat()
share = {
"id": token,
@@ -66,7 +66,7 @@ def create_share(
"vault": vault,
"path": path,
"created_by": created_by,
"created_at": datetime.now(timezone.utc).isoformat(),
"created_at": datetime.now(UTC).isoformat(),
"expires_at": expires_at,
"access_count": 0,
"last_accessed": None,
@@ -86,7 +86,7 @@ def get_share_by_token(token: str) -> dict | None:
return None
if share.get("expires_at"):
expires = datetime.fromisoformat(share["expires_at"])
if datetime.now(timezone.utc) > expires:
if datetime.now(UTC) > expires:
return None
return share
@@ -98,7 +98,7 @@ def record_access(token: str):
share = data["shares"].get(token)
if share:
share["access_count"] = share.get("access_count", 0) + 1
share["last_accessed"] = datetime.now(timezone.utc).isoformat()
share["last_accessed"] = datetime.now(UTC).isoformat()
_write(data)
+2 -2
View File
@@ -7,7 +7,7 @@ Reuses the application audit log (``data/audit.log``, JSON lines) and adds an
from __future__ import annotations
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from backend.audit import _write_entry
@@ -41,7 +41,7 @@ def log_tool_call(
) -> None:
"""Append an ``ai_tool_call`` entry to the audit log."""
_write_entry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"action": "ai_tool_call",
"username": username,
"ip": ip or "unknown",
+1 -1
View File
@@ -244,7 +244,7 @@ class VaultWatcher:
key = event.get('dest') or event['src']
pending[key] = event
except asyncio.TimeoutError:
except TimeoutError:
if pending:
events_to_process = list(pending.values())
pending.clear()
+4 -4
View File
@@ -31,7 +31,7 @@ import os
import socket
import threading
import uuid
from datetime import datetime, timezone
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import urlparse
@@ -275,7 +275,7 @@ def create_webhook(
"url": url,
"events": [e for e in events if e in VALID_EVENTS],
"enabled": True,
"created_at": datetime.now(timezone.utc).isoformat(),
"created_at": datetime.now(UTC).isoformat(),
"last_fired_at": None,
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
"vault": _normalize_vault_filter(vault),
@@ -344,7 +344,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
payload = {
"event": event_type,
"timestamp": datetime.now(timezone.utc).isoformat(),
"timestamp": datetime.now(UTC).isoformat(),
"data": data,
}
body = json.dumps(payload, default=str)
@@ -369,7 +369,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
else:
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
update_webhook(wh["id"], {"last_fired_at": datetime.now(timezone.utc).isoformat()})
update_webhook(wh["id"], {"last_fired_at": datetime.now(UTC).isoformat()})
except Exception as e:
logger.warning(f"Webhook '{wh['name']}' error: {e}")
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.76.1"
version = "2.86.2"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.76.1"
version = "2.86.2"
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.76.1",
"version": "2.86.2",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+166
View File
@@ -0,0 +1,166 @@
# Coffre Secrets ObsiGate — Contrat de distribution (Phases 3 et 4)
Référence du contrat pour les clients serveur-à-serveur : Spark
(issue #21), TikTak, Flowdeck, Kanbante (issue #22). Ce document ne
duplique aucune logique de chiffrement : le chiffrement reste
entièrement côté ObsiGate (enveloppe KEK → DEK → secret, AES-256-GCM).
## 1. Endpoint
```
POST /api/coffres/distribuer
Authorization: Bearer <jeton-consommateur>
Content-Type: application/json
```
Corps (forme à plat, Phase 1b) :
```json
{
"reference": "coffre:<slug>/<nom>",
"contexte_type": "mission",
"contexte_id": "mission-123"
}
```
Forme imbriquée acceptée depuis la Phase 2 (client Spark) :
```json
{
"reference": "coffre:<slug>/<nom>",
"contexte": { "type": "mission", "id": "mission-123" }
}
```
La forme à plat reste supportée ; si les deux formes sont présentes,
la forme à plat est prioritaire. Le contexte est **obligatoire** :
sans `contexte_type`/`contexte_id` (ou sans `contexte.type`/`id`),
la requête est refusée avant toute authentification utile.
Réponse en succès (200) :
```json
{ "reference": "coffre:<slug>/<nom>", "valeur": "<valeur>", "version": 1 }
```
La valeur n'existe qu'en mémoire chez l'appelant : elle ne doit
jamais être écrite dans un fichier, un journal, un prompt, une étape
d'activité ou un livrable. Le patron imposé à chaque app est
**distribuer → utiliser → oublier** ; à la fin de l'opération
(fin de mission, fin de synchronisation), l'environnement qui la
contenait est détruit et rien ne persiste.
Prérequis côté ObsiGate : coffres activés
(`OBSIGATE_SECRETS_ENABLED`, sinon 503 `secrets_desactives`) et KEK
configurée (sinon 503 `kek_absente`).
## 2. Authentification : consommateur nommé, jeton propre
- Un consommateur est créé par un admin :
`POST /api/coffres/consommateurs` (`{"nom": "spark"}`), ou en une
seule opération avec ses octrois :
`POST /api/coffres/consommateurs/avec-octrois`
(`{"nom": "tiktak", "octrois": [{"coffre_slug": "...",
"secret_nom": "..."}]}` ; `secret_nom` absent = tout le coffre).
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la création.
Il n'est jamais stocké en clair (seul son hash SHA-256 est persisté)
et jamais relisté (`GET /api/coffres/consommateurs` ne renvoie que
`id`, `nom`, `jeton_prefixe`, `statut`, `cree_par`, `cree_le`).
- L'`id` du consommateur sert de `jti` révocable :
`DELETE /api/coffres/consommateurs/{id}` révoque immédiatement
toutes ses distributions, sans toucher aux autres consommateurs.
- Ce jeton est **distinct** du jeton MCP de notes et de la session
SSO : une session utilisateur, même admin, ne distribue jamais
(401 `consommateur_invalide`). Il n'existe pas d'endpoint de
distribution par session (voir §6).
- 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.
## 3. Cas Spark (issue #21)
- Consommateur nommé `spark` (convention, non imposée par le code :
l'autorisation porte sur le jeton, pas sur le nom).
- Contexte : `contexte_type = "mission"`, `contexte_id` = identifiant
de la mission. La forme imbriquée
`contexte: {type: "mission", id: <id-mission>}` est acceptée.
- Flux côté agent-commun (hors ObsiGate, rappelé ici pour la
correspondance des erreurs) : le LLM ne voit que la référence
`coffre:<slug>/<nom>` et un accès oui/non, jamais la valeur. Sans
octroi, la mission passe en `acces_requis` en nommant l'accès
manquant. Avec octroi et mission approuvée (enveloppe budgétaire),
le service Spark appelle `/distribuer` et injecte la valeur en
variable d'environnement de l'exécuteur, jamais dans le prompt.
Fin de mission = environnement détruit.
- Révocation dans ObsiGate (consommateur ou octroi) : la mission
suivante repasse en `acces_requis`. Limite assumée : une mission
déjà démarrée conserve son environnement jusqu'à sa fin.
## 4. Erreurs (codes `detail`, stables depuis la Phase 1b)
| HTTP | `detail` | Sens | Correspondance Spark |
| --- | --- | --- | --- |
| 400 | `contexte_manquant` | Contexte absent ou vide. | Erreur d'appel, pas un état d'accès. |
| 400 | `reference_invalide` | Référence mal formée ou nom invalide. | Erreur d'appel. |
| 401 | `consommateur_invalide` | Jeton inconnu (ni MCP, ni session). | Configurer le jeton consommateur. |
| 403 | `consommateur_revoque` | Consommateur révoqué. | `acces_requis` (ré-accorder). |
| 403 | `octroi_absent` | Aucun octroi (ou secret/coffre inexistant — anti-énumération, volontairement indistinct). | `acces_requis` (demander l'octroi). |
| 403 | `octroi_revoque` | Octroi révoqué. | `acces_requis` (ré-accorder). |
| 403 | `octroi_expire` | Octroi expiré (défaut 90 jours à la création). | `acces_requis` (renouveler). |
| 410 | `secret_expire` | Secret expiré, même avec un octroi actif. | Rotation du secret côté ObsiGate. |
| 503 | `secrets_desactives` / `kek_absente` / `crypto_indisponible` | Coffre désactivé, KEK absente, ou bibliothèque absente. | Panne ObsiGate, pas un refus d'accès. |
Un octroi coffre-wide (`secret_id` nul) couvre tous les secrets du
coffre ; un octroi par secret ne couvre que ce secret. Un octroi
révoqué ne doit jamais être masqué par l'absence d'un autre octroi
(priorité : ok > révoqué > expiré > absent).
## 5. Audit
Chaque distribution est auditée (`distribution_ok` ou
`distribution_refusee`, avec référence, consommateur, contexte et
code de résultat). **Aucune entrée d'audit ne contient jamais une
valeur, un texte chiffré, un nonce ou un jeton.** La route journal
(`GET /api/coffres/{slug}/journal`) filtre en défense en profondeur
ces champs même s'ils apparaissaient un jour dans une entrée.
La copie manuelle à usage unique (admin, via consommateur temporaire
créé puis révoqué immédiatement, valeur copiée au presse-papiers
sans affichage) est auditée comme une distribution de contexte
`copie_manuelle`, avec la trace distincte de ce contexte.
## 6. Limites et restes à faire (Phases 3–4, côté ObsiGate)
- **Isolation workspace Flowdeck** : le type de cible `workspace`
existe pour les octrois, mais `/distribuer` n'autorise aujourd'hui
que les octrois de type `consommateur`. L'isolation « un workspace
ne peut pas utiliser l'octroi d'un autre » repose donc, pour
l'instant, sur le serveur Flowdeck lui-même (il ne demande que les
références de son workspace, avec son propre jeton consommateur).
Un contrôle serveur strict (contexte workspace vérifié côté
ObsiGate contre un octroi `workspace`) reste à spécifier et à
implémenter si Bruno l'exige — il changerait la sémantique
d'autorisation de `/distribuer` et doit être coordonné avec les
apps clientes avant tout changement.
- **Distribution par session** : non implémentée, volontairement.
Elle contredirait la règle Phase 1b (distribution réservée aux
consommateurs) et exigerait une ré-authentification qui n'existe
pas encore dans ObsiGate. La copie manuelle reste le patron
supporté (consommateur temporaire, admin seulement).
- **Clients par app** (TikTak, Flowdeck, Kanbante, agent-commun) :
à implémenter dans chaque dépôt, en branche + PR, jamais mergés
sans go. Patron commun : le serveur distribue, utilise, oublie ;
aucune valeur en cache ; si ObsiGate est indisponible, l'opération
échoue proprement, sans repli sur une ancienne valeur.
- **Lecteurs internes ObsiGate** (`ai.py`, `tools/secrets.py`) :
toujours branchés sur `api_keys.json` en clair (voir le document
de sauvegarde/restauration, §5) ; leur branchement sur le coffre
est un travail séparé de la présente Phase 2.
+390
View File
@@ -0,0 +1,390 @@
# Coffre Secrets ObsiGate — Déploiement et mise en service (Phase 5, issue #23)
Document de déploiement du coffre Secrets, issu des Phases 1a/1b
(issues #17, #18), 2 (issue #20), 3 (issue #21, côté agent-commun)
et 4 (issue #22, côté TikTak, Flowdeck, Kanbante).
**Règles de gouvernance (non négociables) :** travail en branche +
pull request, jamais sur `main`, jamais de merge, de release, de tag,
de fermeture d'issue ni de changement de label sans le go explicite
de Bruno. Ce document ne décrit aucune valeur réelle : les exemples
utilisent des valeurs fictives (`TEST-VALUE-XXXX`) et aucune KEK
n'est jamais écrite ici, ni dans un journal, ni dans une note, ni
dans le dépôt.
Documents compagnons (même branche de référence, Phase 2) :
- `docs/COFFRE-contrat-distribution.md` — contrat
`POST /api/coffres/distribuer` pour Spark, TikTak, Flowdeck,
Kanbante (authentification par consommateur, contextes, codes
d'erreur stables).
- `docs/COFFRE-sauvegarde-restauration.md` — sauvegarde
(`data/coffres.json` + `data/coffres_valeurs.json`, 0600) et
restauration sur instance vierge, perte de KEK.
## 1. Prérequis et variables d'environnement
### 1.1 Dépendance
- `cryptography>=44.0` dans `backend/requirements.txt` (dépendance
Phase 1a). Sans elle, les opérations de chiffrement répondent
503 `crypto_indisponible` : c'est un mode dégradé explicite, pas
un repli en clair. Il n'y a jamais de stockage ni de distribution
de valeur en clair.
- Chiffrement par enveloppe AES-256-GCM : KEK (256 bits) → DEK par
coffre → valeur du secret. Un tag GCM invalide refuse le
déchiffrement (testé : `tests/test_coffre_secrets.py`,
`test_tag_invalide_refuse`).
### 1.2 Variables
| Variable | Rôle | Défaut |
| --- | --- | --- |
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Tant qu'elle n'est pas à `true`, **toutes** les routes coffre répondent 503 `secrets_desactives`. | désactivé |
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). **Prioritaire** sur le fichier si les deux sont définis. | absente |
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier contenant la KEK, **en 0600, hors dépôt et hors arborescence des vaults**. Un fichier trop permissif (droits groupe/autre) est refusé explicitement (`KEKInvalideError`). | absent |
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut) : `coffres.json`, `coffres_valeurs.json` et l'historique `api_keys.json`. | `data` |
La KEK est générée une seule fois (admin, coffres activés) via
`POST /api/coffres/admin/generer-kek` ; ObsiGate **ne la stocke
jamais**. Au moment de sa génération : la copier immédiatement
dans un gestionnaire de mots de passe ou sur un support hors ligne,
hors du serveur ; vérifier la copie en la relisant depuis le
gestionnaire, jamais depuis l'écran qui ne l'affichera plus.
Ne jamais la coller dans le dépôt, dans une note ObsiGate, dans le
même dossier que `data/`, ni dans un journal.
**Recommandation de déploiement : préférer `OBSIGATE_KEK_FILE`**
(fichier 0600) à `OBSIGATE_KEK` en variable d'environnement, pour
éviter que la KEK apparaisse dans un historique de processus ou un
dump d'environnement. Si les deux sont définis, `OBSIGATE_KEK`
l'emporte — vérifier qu'aucune ancienne valeur ne traîne dans
l'environnement au moment de basculer sur le fichier.
### 1.3 Ce que la sauvegarde contient (rappel)
Deux fichiers seulement portent le coffre, tous deux en 0600 :
- `data/coffres.json` : métadonnées des coffres, DEK chiffrées,
consommateurs (hashes SHA-256 des jetons seulement, jamais le
jeton en clair), octrois, métadonnées des secrets ;
- `data/coffres_valeurs.json` : versions chiffrées des secrets.
Copiés seuls, ils ne donnent accès à aucune valeur. La KEK ne fait
pas partie de la sauvegarde : elle est copiée à part, hors serveur.
Sauvegarder les deux fichiers et la KEK au même endroit annulerait
la protection du chiffrement.
## 2. Décisions ouvertes de la spec — tranchées par défaut
Les décisions ouvertes de la spec (§15, §16), rappelées dans
l'issue #23, sont confirmées comme tranchées selon les défauts
recommandés, en attendant une décision contraire explicite de
Bruno. Aucune d'elles ne nécessite de changement de code pour la
mise en service décrite ci-dessous :
| Décision | Choix confirmé par défaut |
| --- | --- |
| Glyphe du nœud Secrets | Cadenas U+1F510 (🔐), distinct de la maison du Home et du diamant du Partage. Le nœud est un nœud virtuel sous le Home de l'utilisateur courant seulement (voir §4). |
| Stockage de la KEK | Par fichier 0600, hors dépôt (`OBSIGATE_KEK_FILE`) ; `OBSIGATE_KEK` reste supportée comme solution de secours. |
| Expiration des octrois | 90 jours par défaut à la création, renouvelable (création d'un nouvel octroi). Un octroi expiré refuse la distribution (`octroi_expire`, 403) ; un octroi révoqué refuse dès l'appel suivant (`octroi_revoque`, 403). |
| Copie manuelle | Presse-papiers seul, sans affichage de la valeur. Patron supporté : consommateur temporaire créé par un admin puis révoqué immédiatement, auditée comme distribution de contexte `copie_manuelle`. **Aucun endpoint dédié de copie par session n'est ajouté** : il contredirait la règle « distribution réservée aux consommateurs » et exigerait une ré-authentification qui n'existe pas encore — décision volontaire, à trancher avec Bruno avant toute implémentation. |
| Coffres partagés | Phase 2 ultérieure. En mise en service : coffres personnels d'abord, plus le coffre d'instance « ObsiGate interne » (slug `obsigate-interne`, admin seulement) issu de l'import. |
## 3. Mise en service pas à pas
**Ordre impératif.** Chaque étape est validée avant de passer à la
suivante. En particulier : **les lecteurs internes sont branchés
sur le coffre AVANT toute purge du clair** (étape 4 avant étape 7),
et la purge n'a lieu qu'après validation complète (étape 6).
### Étape 0 — Préparer, coffre encore désactivé
1. Déployer le code coffre (Phases 1–2) sur l'instance, **sans
activer le flag** : `OBSIGATE_SECRETS_ENABLED` reste absent ou à
`false`. Vérifier que toutes les routes `/api/coffres/*`
répondent 503.
2. Sauvegarder l'état actuel : copie de `data/` (dont
`api_keys.json`) et de la configuration, hors serveur.
3. Générer la KEK (voir §1.2) et la copier hors serveur. Ne pas
encore la référencer dans la configuration de l'instance si
l'étape 1 n'est pas terminée — une KEK présente sans flag actif
ne distribue rien, mais sa présence doit rester traçable.
### Étape 1 — Créer les consommateurs et les octrois
Avant d'activer le flag, préparer les consommateurs nommés, un par
application cliente (convention de nommage, non imposée par le
code — l'autorisation porte sur le jeton, pas sur le nom) :
| Consommateur | Usage | Contexte de distribution |
| --- | --- | --- |
| `spark` | Agent Spark (agent-commun, Phase 3) | `contexte_type = "mission"`, `contexte_id` = id de mission (forme imbriquée `contexte: {type, id}` acceptée) |
| `tiktak` | Synchronisation TikTak (Phase 4) | `contexte_type = "tiktak_sync"`, `contexte_id` = id utilisateur |
| `flowdeck` | Connecteurs Flowdeck (Phase 4) | Contexte workspace, vérifié côté Flowdeck (voir §5) |
| `kanbante` | Webhooks Kanbante (Phase 4) | Contexte webhook, distribution à chaque appel puis oubli |
Création en une seule opération avec les octrois :
```
POST /api/coffres/consommateurs/avec-octrois
{"nom": "tiktak", "octrois": [{"coffre_slug": "...", "secret_nom": "..."}]}
```
- `secret_nom` absent = tout le coffre (octroi coffre-wide).
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la
création. Le noter immédiatement dans le coffre de
configuration de l'application cliente concernée, jamais dans
une note ObsiGate ni dans le dépôt. Il n'est jamais relisté :
`GET /api/coffres/consommateurs` ne renvoie que `id`, `nom`,
`jeton_prefixe`, `statut`, `cree_par`, `cree_le`.
- La révocation est immédiate : `DELETE
/api/coffres/consommateurs/{id}` révoque toutes les
distributions de ce consommateur, sans toucher aux autres.
Une mission Spark déjà démarrée conserve son environnement
jusqu'à sa fin ; la mission suivante repasse en `acces_requis`.
À ce stade, les consommateurs et octrois peuvent être créés alors
que les secrets n'existent pas encore : les octrois pointent vers
des coffres/secrets à créer aux étapes suivantes. Vérifier la
liste des consommateurs et des octrois avant d'activer le flag.
### Étape 2 — Activer le flag
1. Définir `OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt) puis
`OBSIGATE_SECRETS_ENABLED=true`.
2. Redémarrer l'instance.
3. Vérifier : la vue Secrets s'ouvre ; `GET /api/coffres` répond
200 (liste vide ou coffres existants) ; sans KEK valide, les
routes répondent 503 `kek_absente` — dans ce cas, corriger la
KEK avant toute création de secret.
### Étape 3 — Importer l'existant (admin seulement)
Bouton « Importer l'existant » de la vue Secrets (admin
seulement), en deux temps séparés :
1. **Aperçu** : `GET /api/coffres/migration/api-keys` liste les
candidats de `data/api_keys.json` (clés IA et clés d'outils).
2. **Import** : `POST /api/coffres/migration/api-keys/import`
copie les entrées avec valeur vers le coffre d'instance
« ObsiGate interne » (slug `obsigate-interne`), chiffrées.
Un secret déjà présent n'est **jamais écrasé**.
`api_keys.json` n'est **pas modifié** par l'import.
Vérifier après import : les secrets importés apparaissent dans la
vue Secrets avec `has_value = true`, et aucune valeur n'est
visible (métadonnées seules : nom, type, version, dates, octrois
actifs — jamais de valeur, jamais de texte chiffré, jamais de
nonce).
### Étape 4 — Brancher les lecteurs internes AVANT toute purge
**Point de vigilance principal de la mise en service.** Les
lecteurs actuels d'ObsiGate (`backend/ai.py`,
`backend/tools/secrets.py`) lisent encore `data/api_keys.json`
directement ; ils ne sont pas encore branchés sur le coffre
(reste à faire explicite des Phases 2–5). Tant que ce branchement
n'est pas livré et validé, **purger le clair priverait l'IA et les
outils de leurs clés**.
Ordre à respecter :
1. Ne pas purger.
2. Livrer (en branche + PR, jamais mergé sans go) le branchement
des lecteurs internes sur une distribution depuis le coffre
d'instance, puis le valider sur l'instance : l'IA et les outils
fonctionnent avec `api_keys.json` rendu temporairement
inaccessible en lecture (test de coupure contrôlée), ou à
défaut avec un secret de test distribué depuis le coffre.
3. Seulement après cette validation, passer à l'étape 7.
Si Bruno accepte une interruption temporaire de l'IA/outils, le
documenter explicitement avant la purge ; sinon, la purge attend
le branchement.
### Étape 5 — Brancher les applications clientes
Pour chaque application (branches dédiées, jamais mergées sans
go), appliquer le patron commun **distribuer → utiliser →
oublier** : le serveur distribue à chaque opération, utilise la
valeur en mémoire, puis l'oublie ; aucune valeur en cache, aucune
persistance. Si ObsiGate est indisponible, l'opération échoue
proprement, sans repli sur une ancienne valeur.
- **Spark (agent-commun, Phase 3)** : identités référencées
`coffre:<slug>/<nom>` (slug 3–64, nom 3–128, minuscules /
chiffres / `-` / `_`), sans colonne valeur ; le LLM ne voit que
la référence et un accès oui/non. Sans octroi, la mission passe
en `acces_requis` en nommant l'accès manquant ; avec octroi et
mission approuvée (enveloppe budgétaire), distribution puis
injection en variable d'environnement de l'exécuteur, jamais
dans le prompt, jamais dans les étapes d'activité. Fin de
mission = environnement détruit.
- **TikTak (Phase 4)** : référence seule stockée ; distribution
serveur à chaque synchro ; masquage côté Android (ni référence
complète ni valeur dans les réponses vues par une clé `tck_`).
Les configurations existantes sont marquées `a_resaisir = 1`
(migration 019 : colonne en clair supprimée, référence vide) :
il n'y a **pas de conversion automatique** — chaque
configuration doit être re-saisie par référence.
- **Flowdeck (Phase 4)** : `workspace_id` + référence sur les
connecteurs ; distribution en mémoire ; isolation A vs B
testée (un workspace ne distribue pas avec l'octroi d'un autre).
Les connecteurs natifs Gitea/GitHub et les clés LLM ne sont pas
basculés ; pas de sélecteur UI (API seulement).
- **Kanbante (Phase 4)** : secret webhook migré en référence,
distribuée à chaque webhook puis oubliée ; client générique
réutilisable. Les jetons forge chiffrés Fernet ne sont **pas**
migrés (distribution par appel requise, incompatible avec le
cache `ForgeRegistry`) ; les secrets webhook historiques en
clair restent acceptés par compatibilité, sans migration
automatique.
### Étape 6 — Valider (avant purge)
Exécuter la checklist de validation de l'instance réelle (§4).
Tant qu'un point échoue, ne pas purger et ne pas considérer la
mise en service comme terminée.
### Étape 7 — Purger, seulement après validation
`POST /api/coffres/migration/api-keys/purger`, **après
confirmation explicite** (`confirmer = true`), et uniquement pour
les noms migrés. Sans confirmation, rien n'est supprimé — jamais
automatiquement.
Après purge, vérifier :
- `api_keys.json` ne contient plus les noms purgés ;
- l'IA et les outils fonctionnent toujours (lecteurs branchés,
étape 4) ;
- une distribution de test via un consommateur existant aboutit
(succès seul vérifié, valeur ni affichée ni consignée).
### Étape 8 — Sauvegarder et tester la restauration
1. Sauvegarder `data/coffres.json` + `data/coffres_valeurs.json`
(0600) par la sauvegarde habituelle du serveur, et la KEK à
part, hors serveur (§1.3).
2. Rejouer la restauration sur une instance vierge (procédure
`docs/COFFRE-sauvegarde-restauration.md` §3 : arrêt de la
cible, copie des deux fichiers en 0600, même KEK, redémarrage,
vérification `has_value = true` + distribution de test +
`distribution_ok` dans le journal). La suite automatisée
(`tests/test_coffre_secrets.py`, classe
`TestRestaurationVierge`) simule cette restauration ; elle ne
remplace pas un essai sur instance réelle.
3. En cas de perte de KEK : perte d'accès assumée, par
construction — aucun moyen de récupérer les valeurs. Recréer
les coffres avec une nouvelle KEK, régénérer les valeurs côté
fournisseur, révoquer les anciennes côté fournisseur, recréer
consommateurs et octrois. Ne jamais tenter de « réparer » les
anciens fichiers avec une nouvelle KEK (le déchiffrement
échouera sur tag GCM invalide, ce qui est attendu).
## 4. Checklist de validation par Bruno sur instance réelle
Validation exigée par l'issue #23 avant tout usage réel. Chaque
point est validé sur capture, comme pour les autres livraisons :
- [ ] **Sidebar** : le nœud « Secrets » (cadenas U+1F510)
apparaît sous le Home de l'utilisateur courant seulement, et
pas sous les autres vaults ; cliquer dessus ouvre la vue
Secrets sans naviguer dans les fichiers.
- [ ] **Secret de test** : création d'un secret de test
(valeur fictive, ex. `TEST-VALUE-XXXX`) dans un coffre
personnel, puis lecture : métadonnées seules, aucune valeur
affichée, aucun texte chiffré, aucun nonce.
- [ ] **Journal d'audit** : après une distribution de test, le
journal contient `distribution_ok` pour la référence, et un
`grep` du journal sur la valeur de test ne renvoie **rien**
(ni valeur, ni texte chiffré, ni nonce, ni jeton).
- [ ] **Autorisations** : un admin ne voit pas le coffre
personnel d'autrui ; une ACL `*` sur un vault ne donne aucun
droit sur un coffre ; une session utilisateur, même admin, ne
distribue pas (401 `consommateur_invalide`).
- [ ] **Distribution** : contexte manquant refusé (400
`contexte_manquant`) ; octroi révoqué refusé dès l'appel
suivant (403 `octroi_revoque`) ; octroi expiré refusé (403
`octroi_expire`) ; secret expiré refusé (410 `secret_expire`).
- [ ] **Import / purge** : l'import ne modifie pas
`api_keys.json` et n'écrase pas un secret existant ; la purge
sans confirmation explicite ne supprime rien.
- [ ] **Restauration** : sauvegarde + KEK externe restaurées sur
une instance vierge, distributions fonctionnelles.
- [ ] **Intégrations** : mission Spark en `acces_requis` sans
octroi, puis injection exécuteur sans trace dans les étapes ;
synchro TikTak sans valeur côté Android ; isolation workspace
Flowdeck (A ne distribue pas avec l'octroi de B).
- [ ] **Modale** : la modale de création de secret n'est plus
coupée en haut (`.obsigate-modal` bornée au viewport, corps
seul défilant, en-tête et pied toujours visibles).
## 5. Limites connues à la mise en service
À lire avant de déclarer la mise en service terminée :
- **Lecteurs internes non branchés** (`ai.py`,
`tools/secrets.py` lisent encore `api_keys.json` en clair) :
purger avant de les brancher priverait l'IA/outils de leurs
clés (§3, étape 4).
- **Endpoint dédié de copie manuelle par session non ajouté**
(décision volontaire, §2) : la copie manuelle passe par un
consommateur temporaire admin, presse-papiers seul.
- **Isolation workspace Flowdeck non contrôlée côté ObsiGate** :
`/distribuer` n'autorise aujourd'hui que les octrois de type
`consommateur` ; l'isolation workspace repose sur le serveur
Flowdeck lui-même (il ne demande que les références de son
workspace, avec son propre jeton). Un contrôle serveur strict
reste à spécifier si Bruno l'exige.
- **Identités agent-commun** : détenteur libre hors coffre
conservé (historique) ; révocation Identités non propagée vers
ObsiGate (pas d'endpoint) ; exécuteurs CLI synchrones
uniquement.
- **TikTak** : « sélecteur » = saisie validée, pas de liste
déroulante (aucune API de listage côté ObsiGate) ;
configurations existantes à re-saisir, pas de conversion
automatique.
- **Flowdeck** : natifs Gitea/GitHub et clés LLM non basculés ;
pas de sélecteur UI (API seulement) ; `workspace_id` pas encore
branché depuis AgentEngine.
- **Kanbante** : jetons forge chiffrés Fernet non migrés ;
pas de migration automatique des secrets webhook historiques.
- **Recherche / graphe / index** : par construction, un coffre
n'est pas un vault de notes — il ne passe ni par l'indexeur, ni
par la recherche, ni par le graphe, et aucun outil MCP de secret
n'existe. Aucune métadonnée de secret ne doit apparaître dans
ces index (vérification par revue de code, pas de suite
automatisée dédiée — voir le bilan Phase 5).
## 6. Repli (rollback)
- Désactiver le flag (`OBSIGATE_SECRETS_ENABLED` absent ou
`false`) : toutes les routes coffre répondent 503, les fichiers
`coffres.json` / `coffres_valeurs.json` restent intacts et
re-distribueront dès réactivation avec la même KEK.
- Révoquer un consommateur (`DELETE
/api/coffres/consommateurs/{id}`) coupe immédiatement ses
distributions, sans toucher aux autres consommateurs ni aux
secrets.
- Ne jamais « réparer » des fichiers chiffrés avec une nouvelle
KEK, ne jamais restaurer `coffres.json` sans
`coffres_valeurs.json` (et inversement), ne jamais copier la
KEK au même endroit que la sauvegarde.
## 7. Références
- Spec « Coffre de secrets ObsiGate » §6 (autorisations), §7/§10/§11
(API), §13/§14 (sauvegarde/restauration), §15/§16 (tests,
déploiement).
- Issues ObsiGate : #17 (Phase 1a), #18 (Phase 1b), #19 (Phase 1c
UI), #20 (Phase 2), #21 (Phase 3, Spark), #22 (Phase 4, TikTak /
Flowdeck / Kanbante), #23 (Phase 5, ce document).
- Suites de tests : `tests/test_coffre_secrets.py` (ObsiGate, 35
tests), `tests/test_identites_coffre_phase3.py` (agent-commun,
16 tests), `tests/test_coffre_reference.py` (TikTak, 17 fonctions
/ 26 cas), `tests/test_coffre_reference_flowdeck.py` (Flowdeck,
16 tests), `tests/test_coffre_client.py` (Kanbante, 11 fonctions
/ 25 cas). Voir le bilan Phase 5 pour la matrice de couverture
complète et les manques.
+155
View File
@@ -0,0 +1,155 @@
# Coffre Secrets ObsiGate — Sauvegarde et restauration (Phase 2, issue #20)
Ce document décrit la seule procédure supportée pour sauvegarder et
restaurer le coffre Secrets, ainsi que le cas de perte de la KEK.
Il complète la spécification du coffre (§13, §14) ; il ne décrit
aucune valeur réelle — les exemples utilisent des valeurs fictives.
## 1. Ce qui constitue une sauvegarde
Deux fichiers, et seulement ces deux fichiers, portent le coffre :
| Fichier | Contenu |
| --- | --- |
| `data/coffres.json` | Coffres (métadonnées + DEK chiffrées), consommateurs (hashes de jetons seulement), octrois, métadonnées des secrets. |
| `data/coffres_valeurs.json` | Versions chiffrées des secrets (AES-256-GCM). |
Ces deux fichiers forment une **sauvegarde ordinaire chiffrée** :
copiés seuls, ils ne donnent accès à aucune valeur — chaque DEK est
chiffrée par la KEK, chaque valeur est chiffrée par la DEK de son
coffre. Ils peuvent donc transiter par la sauvegarde habituelle du
serveur, avec les mêmes permissions restrictives que sur l'instance
(0600). Ils ne doivent jamais être déposés dans le dépôt Git, ni dans
une note ObsiGate, ni dans un journal.
La **KEK ne fait pas partie de la sauvegarde** : elle est copiée à
part, hors serveur (voir §2). Sauvegarder les deux fichiers et la KEK
au même endroit annulerait la protection du chiffrement.
## 2. Sauvegarder la KEK (copie externe, hors serveur)
La KEK est une clé maître 256 bits (base64 de 32 octets), générée une
seule fois via `POST /api/coffres/admin/generer-kek` (admin, coffres
activés) et **jamais stockée par ObsiGate**. Au moment de sa
génération :
1. Copiez-la immédiatement dans un gestionnaire de mots de passe ou
sur un support hors ligne, hors du serveur ObsiGate.
2. Ne la collez jamais dans le dépôt, jamais dans une note ObsiGate,
jamais dans le même dossier que `data/`, jamais dans un journal.
3. Vérifiez la copie (relisez-la depuis le gestionnaire, pas depuis
l'écran qui ne l'affichera plus jamais).
Variables d'environnement (documentées aussi dans `.env.example`,
sans aucune valeur) :
| Variable | Rôle | Défaut |
| --- | --- | --- |
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Toute route répond 503 tant qu'elle n'est pas activée. | désactivé |
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). Prioritaire sur le fichier. | absente |
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier 0600, hors dépôt et hors arborescence des vaults, contenant la KEK. Un fichier trop permissif est refusé. | absent |
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut), donc des deux fichiers du coffre et de `api_keys.json`. | `data` |
**Aucune autre variable ne doit contenir une valeur de secret du
coffre.** En particulier, ne créez jamais de variable du type
`COFFRE_<nom>` avec une valeur : la seule valeur externe autorisée est
la KEK, et elle n'est pas une valeur de secret du coffre.
## 3. Restauration sur une instance (y compris vierge)
Prérequis : une instance ObsiGate avec le coffre activé
(`OBSIGATE_SECRETS_ENABLED`), et la **même KEK** que l'instance
d'origine.
1. Arrêtez l'instance cible (ou, à défaut, assurez-vous qu'aucune
écriture n'a lieu pendant la copie).
2. Restaurez les deux fichiers `coffres.json` et
`coffres_valeurs.json` dans le répertoire de données cible, en
permissions 0600.
3. Re-présentez la même KEK, via `OBSIGATE_KEK` ou via
`OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt).
4. Redémarrez l'instance.
5. Vérifiez, sans jamais afficher ni journaliser de valeur :
- la vue Secrets liste les coffres et les secrets avec
`has_value = true` ;
- une distribution de test via un consommateur existant (ou un
consommateur de test, révoqué ensuite) aboutit — côté serveur,
le succès seul est vérifié, la valeur n'est ni affichée ni
consignée ;
- le journal d'audit contient `distribution_ok` pour cette
référence.
**Test de restauration obligatoire avant la mise en service réelle
(issue #20)** : la procédure ci-dessus a été automatisée dans la
suite de tests (`tests/test_coffre_secrets.py`, classe
`TestRestaurationVierge`) — copie des deux fichiers vers un
répertoire vierge, même KEK, distributions fonctionnelles — et doit
aussi être rejouée une fois sur une instance réelle par l'opérateur
avant de considérer la sauvegarde comme valide.
## 4. Perte de la KEK : perte d'accès assumée, par construction
Si la KEK est perdue, **il n'existe aucun moyen de récupérer les
valeurs** : les DEK ne sont stockées que chiffrées par la KEK, et les
valeurs ne sont stockées que chiffrées par les DEK (AES-256-GCM,
authentifié). Il n'y a ni clé de secours, ni dérivation depuis le
secret d'application, ni porte dérobée.
Procédure en cas de perte :
1. Considérez toutes les valeurs du coffre comme perdues.
2. Créez de nouveaux coffres avec une nouvelle KEK.
3. Pour chaque secret perdu, régénérez la valeur **côté fournisseur**
(nouvelle clé API, nouveau jeton) et créez un nouveau secret avec
cette nouvelle valeur ; révoquez les anciennes valeurs côté
fournisseur, car elles restent valides hors d'ObsiGate tant qu'elles
ne sont pas révoquées là-bas.
4. Recréez les consommateurs et les octrois (les anciens octrois
pointent vers des secrets qui n'existent plus dans les nouveaux
coffres).
5. Ne tentez pas de « réparer » les anciens fichiers avec une nouvelle
KEK : le déchiffrement échouera (tag GCM invalide), ce qui est le
comportement attendu, pas un bug.
La rotation KEK (`POST /api/coffres/admin/rotation-kek`) n'est pas
une récupération : elle exige l'ancienne KEK encore valide pour
re-chiffrer les DEK, puis le remplacement de la KEK dans
l'environnement ou le fichier KEK.
## 5. Migration `api_keys.json` (Phase 2) — avertissement important
Le bouton « Importer l'existant » de la vue Secrets (admin seulement)
copie les clés IA et clés d'outils de `data/api_keys.json` vers le
coffre d'instance « ObsiGate interne » (slug `obsigate-interne`),
en deux temps :
1. **Import** : les entrées avec valeur sont chiffrées dans le coffre
(un secret déjà présent n'est jamais écrasé) ; `api_keys.json`
n'est pas modifié.
2. **Purge** : après confirmation explicite, et uniquement pour les
noms migrés, les entrées sont supprimées du clair. Sans
confirmation, rien n'est supprimé — jamais automatiquement.
**Avertissement** : les lecteurs actuels d'ObsiGate
(`backend/ai.py`, `backend/tools/secrets.py`) lisent encore
`api_keys.json` directement ; ils ne sont pas encore branchés sur le
coffre. Purger le clair prive donc ces lecteurs de la clé tant que
leur lecteur n'est pas branché sur une distribution depuis le coffre.
Ne purgez que si vous avez vérifié que le consommateur concerné
distribue depuis le coffre, ou si vous acceptez cette interruption.
Ce branchement des lecteurs internes est un reste à faire explicite
(voir §6).
## 6. Restes à faire (après cette Phase 2)
- Brancher les lecteurs internes (`ai.py`, `tools/secrets.py`) sur
une distribution depuis le coffre d'instance, pour que la purge du
clair ne coupe pas l'IA ni les outils.
- Rejouer le test de restauration sur une instance réelle (la suite
automatisée simule une instance vierge, elle ne remplace pas un
essai sur l'instance de production).
- Endpoint de distribution par session utilisateur (copie manuelle
sans consommateur temporaire) : non ajouté ici, car il contredirait
la règle Phase 1b (distribution réservée aux consommateurs) et
exigerait une ré-authentification qui n'existe pas encore — décision
à prendre avec l'opérateur avant toute implémentation.
+9
View File
@@ -225,6 +225,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-115* | Éditeur Forge : cases à cocher collées au texte dans l'aperçu, aperçu qui flashe à la frappe, menu « dimensions du tableau » coupé hors écran sur un document court | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (CSS aperçu, `renderPreview`, `showTableForm`, `showLangPicker`) | Ouvrir un `.md` avec `- [ ]`, activer l'aperçu → texte collé au checkbox + flash « Rendering... » ; `/table` sur un document d'une ligne → forme partiellement visible | (1) règle `.task-list-item input[type=checkbox] { margin-right: 6px }` ajoutée au `<style>` de l'aperçu (miroir `style.css:2303`) ; (2) `renderPreview` ne remplace plus le contenu par le spinner (affiché seulement si vide) + flag `_previewDirty` pour rejouer la dernière frappe ; (3) `.inline-form`/`.lang-picker` en `position: fixed` (coordonnées viewport) avec clamp bas/droite comme le menu slash + `stopPropagation` sur les clics qi/slash (le listener document refermait la forme) | Vérifié en Playwright (instance E2E) : marginRight 6 px + `list-style: none`, MutationObserver : aucun « Rendering... » pendant 14 frappes et dernier état rendu, forme table `bottom 193 < vh 900` sur document 1 ligne + insertion OK |
| *BUG-116* | Éditeur Forge : après insertion d'un tableau (ex. 4 lignes), la textarea reste d'une seule ligne au lieu de s'agrandir | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (`insertAtCursor`, `wrapSelection`) | Insérer un tableau via le menu : le contenu contient bien les nouvelles lignes mais la hauteur de la textarea ne change pas | Les mutations programmatiques de `ta.value` ne déclenchent pas l'événement `input` — seul handler qui rappelait `autoHeight()`/`updateLineNumbers()` (les handlers clavier, eux, les appelaient à la main). Ajout des deux appels dans `insertAtCursor` et `wrapSelection`, les deux points de passage de toutes les insertions (tableau, code, mermaid, checklist, gras multi-ligne) | Vérifié en Playwright sur l'instance E2E (qi → Table, 4 lignes, sur doc d'une ligne) : hauteur 51 → 189 px, 8 lignes rendues, gutter aligné | ### TODOs techniques (améliorations / nouvelles tâches)
| *BUG-117* | Drag & drop des fichiers dans la sidebar : le déplacement d'un répertoire à un autre ne fait plus rien, sans message | 🟢 corrigé | P1 | 📱 frontend / sidebar | IA | `frontend/js/dragdrop.js` (`onDrop`) | Glisser un fichier sur un dossier : aucune requête `/api/move`, aucun toast | La détection `isExternalFiles` dans `onDrop` (`items.length > 0`, ajoutée par #89) comptait aussi les formats string du drag interne (`tree:vault/path/type` posés par `setData` au `dragstart`) : tout déplacement partait dans la branche upload où rien ne passe le filtre `kind === 'file'` → drop avalé silencieusement. Corrigé à la racine : `dragState` (interne) prioritaire + items réellement fichiers. Livré avec #201 : menu contextuel Déplacer/Dupliquer réutilisant `promptTargetFolder`, emplacement actuel pré-sélectionné | Vérifié Playwright instance E2E : glisser `note1.md` → `d1` = toast « Moved file to d1 » + chemin déplacé ; 4 tests `TestDuplicateApi` |
| *BUG-119* | Panneau Spark : le « mode console » (surimpression iframe de la console du service d'agents) est bloqué par le navigateur (« This content is blocked ») | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Panneau Spark → « Console ⤢ » : l'iframe reste sur la page d'erreur du navigateur | `frame-src 'self' blob: https://agent.dracodev.net` dans la CSP servie ; test `tests/test_security_headers.py::test_csp_frame_src_allows_agent_console` | Cause : `frame-src` n'autorisait pas l'origine du service d'agents, pourtant déjà présente dans `connect-src` (le panneau texte fonctionnait). Aucune autre directive modifiée. Redéploiement Docker requis |
| *BUG-120* | Menu inter-apps : sur l'environnement dev, les URL sœurs servies (`window.DRACO_URLS` : ObsiGate, Flowdeck, TikTak, Kanbante) restent en noms **prod** alors que l'URL de l'agent est bien en `-dev` | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/draco_config.py` (`app_url`, `_suffixe_environnement`) | Sur og-dev : `window.DRACO_URLS` donne `agent: https://agent-dev.dracodev.net` mais `tiktak: https://tiktak.dracodev.net` (prod) — le `.env` dev ne déclare pas les quatre `DRACO_<APP>_URL` | `backend/draco_config.py` : résolution sœur en 3 temps — explicite, puis **dérivation d'environnement** depuis l'hôte de `agent_url()` (`agent<SUFFIXE>.dracodev.net` → `https://<slug><SUFFIXE>.dracodev.net`, slugs `og`/`flowdeck`/`tiktak`/`kanbante` ; hors motif → rien), puis défaut officiel ; Gitea exclue. Tests `tests/test_draco_config.py` (+9 : dérivation `-dev` via `AGENT_EVENEMENTS_URL` et via `DRACO_AGENT_URL`, priorité de l'explicite, 5 hôtes hors motif, suffixe vide, injection) | Suite de #223 : seule l'URL de l'agent avait une dérivation de repli (`AGENT_EVENEMENTS_URL`) ; les sœurs retombaient sur les défauts officiels. Correctif de code demandé par Bruno : l'environnement se déduit de l'agent déjà configuré, sans variable supplémentaire. Redéploiement Docker requis |
| *BUG-122* | Déconnexion du menu compte inerte dans les applications sœurs : la barre commune relaie `deconnexion` à l'hôte, mais la déconnexion globale d'ObsiGate (ACC-3.1) n'est appelable qu'en same-origin (`POST /api/auth/logout`) — aucun point d'entrée navigable pour les autres hôtes | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/routers/sso.py` (`GET /sso/logout`), `backend/auth/router.py` (`apply_global_logout`) | Depuis la console Spark (ou Flowdeck/TikTak/ShaarIt/Hub) : menu compte → « Déconnexion » → rien ne se passe, la session draco reste vivante (vérifié en navigateur réel : cookie présent, `/api/v1/me` toujours 200) | Nouvelle route `GET /sso/logout?return_to=` : exécute la déconnexion globale (logique partagée `apply_global_logout` : jetons natifs refresh/access révoqués, `jti` draco révoqué, cookie effacé sur le domaine parent) puis redirige vers le `return_to` validé (liste blanche https + *.dracodev.net, repli `/`). Tests `tests/test_draco_session.py` (+3) | Signalé par Bruno le 2026-11-06 (capture console), carte fusion #141 ; les hôtes branchent leur action `deconnexion` sur cette route (PR sœurs). BUG-121 reste réservé au bug de couleur « Secrets » du vault Spark (proposé, non ordonné). Redéploiement Docker requis |
| *BUG-123* | Aller-retour SSO non refermé quand la session ObsiGate existe déjà : `GET /sso/authorize?return_to=…` dépose l'utilisateur sur `/?return_to=…`, mais le retour n'est honoré que par `AuthManager.saveToken()` (login frais, MFA, WebAuthn) — les chemins de restauration de session d'`initAuth()` (jeton encore valide en sessionStorage, ou refresh silencieux au démarrage) affichent l'accueil sans jamais suivre le `return_to` : l'utilisateur reste chez ObsiGate et, revenu manuellement à l'application sœur, n'y a toujours aucune session locale | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/auth.js` (`initAuth`, `_honorReturnTo`) | Navigateur avec session ObsiGate active → Kanbante → « Se connecter avec ObsiGate (SSO) » → retour automatique attendu dans Kanbante, connecté ; constaté en pile locale complète (chaîne d'URL journalisée) : arrivée sur l'accueil ObsiGate, retour jamais suivi | `_honorReturnTo()` retourne si la redirection a eu lieu ; les deux chemins de restauration d'`initAuth()` l'invoquent avant `showApp()` (les refresh en cours de session restent sans redirection). Garde : `tests/test_draco_barre.py` ; preuve navigateur complète du chantier (pile locale aux noms publics) | Signalé par Bruno le 2026-11-08 (test réel, compte « testeur » créé au Hub) ; le sous-cas login frais refermait déjà la boucle (vérifié). Fiche `docs/features/entree-sso-bug-123-124.md`. Redéploiement Docker requis |
| *BUG-124* | Nom du compte absent du menu de la barre commune juste après le login : `renderUserSection()` met à jour le bloc latéral et le drapeau admin de la barre, mais ne pousse jamais le nom — le seul poseur d'identité de la barre est le fetch d'amorçage au `DOMContentLoaded`, limité aux sessions déjà présentes au chargement. Après un login SPA sans rechargement : avatar « ✦ » et en-tête « Compte » jusqu'au rechargement forcé | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/auth.js` (`renderUserSection`), `frontend/index.html`, `frontend/draco-barre.js` (vendange commons v0.7.0) | Se connecter dans la SPA puis ouvrir le menu compte de la barre sans recharger : en-tête « Compte » au lieu du nom (reproduit en pile locale, capture) | Barre commune v0.7.0 re-vendangée verbatim (nouvelle API `DRACO_BAR.setUser`, PR dracodev-commons #15) ; `renderUserSection()` appelle `setUser(name)` / `setUser(null)`, le fetch d'amorçage d'`index.html` pousse aussi le nom par `setUser`. Garde : `tests/test_draco_barre.py` | Signalé par Bruno le 2026-11-08 (captures). Fiche `docs/features/entree-sso-bug-123-124.md`. Redéploiement Docker requis |
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|---|---|---|---|---|---|---|---|---|---|
@@ -357,6 +362,10 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-10-21 | BUG-118 | Correction | `frontend/index.html`, `frontend/draco-barre.css`, `tests/test_draco_barre.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-118 — la barre d'apps Fusion était inutilisable en mobile** (retours de Bruno sur captures téléphone). Deux causes : (1) le palier ≤ 700 px de la CSS commune gardait onglets entiers et pastilles domaines — la barre débordait ; la source dracodev-commons passe en **v0.1.2** (spec des barres, § Mobile : marque, onglet de l'app courante, alertes, Spark, avatar ; rail/recherche/domaines/menu masqués au profit des contrôles natifs). (2) Le bouton ☰ de la barre cliquait `sidebar-toggle-btn` (bureau) au lieu de `hamburger-btn` (tiroir mobile) : sur téléphone il ne faisait rien, doublé du vrai hamburger d'ObsiGate juste en dessous. Rail devenu contextuel (`matchMedia` ≤ 768 px, point de bascule d'ObsiGate). Test de non-régression `tests/test_draco_barre.py` (2 tests) | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-15 | BUG-117 + #201 | Correction + feature | `frontend/js/dragdrop.js`, `frontend/js/ui.js`, `frontend/js/upload.js`, `frontend/locales/fr.json`, `frontend/locales/en.json`, `backend/services/mutations.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **BUG-117 — le drag & drop interne de la sidebar était mort en silence.** `onDrop` classait tout dépôt « externe » dès que `dataTransfer.items` était non vide — or un glisser interne dépose 4 formats string (`tree:*`) : chaque déplacement partait dans la branche upload où le filtre `kind === 'file'` ne trouvait rien — aucun appel API, aucun toast. Détection corrigée (vrais fichiers seulement, `dragState` prioritaire). Livré avec **#201 : menu contextuel Déplacer/Dupliquer** (fichiers et dossiers), modal `promptTargetFolder` réutilisé (+ `opts`, emplacement actuel pré-sélectionné), endpoint `POST /api/duplicate/{vault}` (`duplicate_path`, index incrémental, SSE/webhooks, refus inside-self), i18n FR/EN, 4 tests `TestDuplicateApi` + validation Playwright E2E complète | 🟢 corrigé/livré (en attente vérif utilisateur) |
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
| 2026-10-23 | BUG-119 | Correction | `backend/main.py`, `tests/test_security_headers.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md` | **BUG-119 — le « mode console » du panneau Spark V3 (#222) était bloqué par le navigateur** (« This content is blocked ») : la console du service d'agents s'affiche dans une iframe `https://agent.dracodev.net`, mais la CSP servie par `SecurityHeadersMiddleware` limitait `frame-src` à `'self' blob:` — seul le panneau texte passait (`connect-src` autorisait déjà l'origine). Correctif strict : `frame-src 'self' blob: https://agent.dracodev.net`, toutes les autres directives inchangées à l'identique. Test de non-régression `test_csp_frame_src_allows_agent_console` (la CSP servie est analysée et la liste exacte des sources `frame-src` verrouillée). Vérifié : 100 passed (security_headers + csp_nonce + security_hardening + draco_barre + version), ruff propre sur les fichiers touchés (51 erreurs préexistantes ailleurs, inchangées), mypy 0 (120 fichiers) | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-25 | BUG-120 (suite #223) | Correction | `backend/draco_config.py`, `tests/test_draco_config.py`, `.env.example`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md`, `docs/features/devprod-urls-config-223.md` | **BUG-120 — le menu inter-apps de la dev pointait la prod** : `window.DRACO_URLS` servait l'agent en `-dev` mais les quatre sœurs en noms officiels, le `.env` dev ne déclarant pas les `DRACO_<APP>_URL`. Correctif dans `draco_config.app_url()` : après l'explicite, **dérivation d'environnement** — hôte de `agent_url()` de la forme `agent<SUFFIXE>.dracodev.net` sans port → sœur en `<slug><SUFFIXE>.dracodev.net` ; hors motif (autre domaine, LAN, IP, port) aucune dérivation ; Gitea jamais dérivée. Vérifié : `test_draco_config.py` 16 passed, suite complète **1668 passed / 9 skipped**, ruff 0 et mypy 0 sur les fichiers touchés (51 erreurs ruff préexistantes ailleurs, inchangées) | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-11-06 | BUG-122 (fusion #141) | Correction | `backend/routers/sso.py`, `backend/auth/router.py`, `tests/test_draco_session.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/features/deconnexion-globale-bug-122.md`, `VERSION` (+ dérivés) | **BUG-122 — la déconnexion du menu compte ne terminait rien hors ObsiGate** : ajout de `GET /sso/logout`, point d'entrée navigable de la déconnexion globale (logique partagée avec `POST /api/auth/logout`). Vérifié : `test_draco_session.py` 34 passed, ruff 0 et mypy 0 sur les fichiers touchés | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-11-08 | BUG-123 + BUG-124 (chantier entrée SSO) | Correction | `frontend/js/auth.js`, `frontend/index.html`, `frontend/draco-barre.js`, `tests/test_draco_barre.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md`, `docs/features/entree-sso-bug-123-124.md`, `VERSION` (+ dérivés) | **BUG-123 — aller-retour SSO refermé à la restauration de session** : `initAuth()` honore le `return_to` sur ses deux chemins de restauration (jeton valide, refresh silencieux au démarrage), comme le faisait déjà `saveToken` après un login frais. **BUG-124 — nom du compte dans la barre dès le login** : barre commune re-vendangée en v0.7.0 (`DRACO_BAR.setUser`, PR dracodev-commons #15), branchée dans `renderUserSection()` et le fetch d'amorçage. Chaînes prouvées en navigateur réel (pile locale aux noms publics, scénario de Bruno) | 🟢 corrigé (en attente vérif utilisateur) |
---
+144 -3
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.76.1 | **Dernière mise à jour :** 2026-10-22
> **Version :** 2.86.2 | **Dernière mise à jour :** 2026-11-08
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -30,9 +30,145 @@
## 🔵 En cours
### 227. Hub HUB-22 — API machine des sessions draco (liste et révocation)
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-06 | **Statut :** ✅ livré en 2.85.0 (PR #68 fusionnée et release publiée — voir CHANGELOG ; redéploiement Docker requis)
- **Fiche :** [features/hub-sessions-machine-227.md](./features/hub-sessions-machine-227.md)
- **Description :** vague C du plan Hub v2.1 (HUB-22) : le Hub doit lister
les sessions actives d'un utilisateur et les révoquer une à une ou
toutes, via le canal `jti` livré en HUB-13 (#226). Trois routes sous
la même garde machine (`require_machine_admin`) et la même
journalisation d'audit que les routes comptes existantes :
`GET /api/admin/machine/comptes/{username}/sessions`,
`POST …/sessions/{jti}/revoquer` (jamais de révocation
cross-utilisateur) et `POST …/sessions/revoquer-toutes`
(déconnexion globale, sans désactiver le compte).
- **Sous-tâches :**
- [x] `issued_sessions_for_user` + `revoke_one_for_user` (`backend/draco_session.py`)
- [x] Trois routes sessions (`backend/routers/comptes_machine.py`)
- [x] Tests étendus (`tests/test_comptes_machine.py`, `tests/test_draco_session.py`)
### 228. Hub HUB-19 — propagation des rôles du Hub à ObsiGate (consommatrice)
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-06 | **Statut :** ✅ livré en 2.85.1 (voir CHANGELOG ; branche rebasée sur la 2.85.0 avant fusion ; redéploiement Docker requis)
- **Fiche :** [features/hub-roles-obsigate-228.md](./features/hub-roles-obsigate-228.md)
- **Description :** suite de HUB-19 (plan Hub v2 §2.2, décision D3(a)) : le
pilote ShaarIt (0.7.0, en production) a posé le contrat ; ObsiGate —
à la fois émettrice et consommatrice du SSO — le consomme à son tour.
À l'ouverture de session draco, consultation de
`GET {hub}/api/roles/obsigate` (Cookie relayé, délai 2 s), cache
mémoire 60 s par `sub` (horodatage posé avant l'appel). Niveau connu
`aucun` → session draco refusée (réponse explicite en français) ;
routes `/api/auth/admin/*` authentifiées par le canal draco :
niveau `administrateur` exigé quand le rôle est connu. Toute erreur
du Hub → rôle inconnu → comportement actuel. L'authentification
native d'ObsiGate (émettrice, accès de secours) n'est jamais touchée ;
validation locale du jeton, émission et révocations inchangées,
aucun rôle dans le jeton.
- **Sous-tâches :**
- [x] Client `backend/hub_roles.py` (contrat D3(a), cache/repli, URL du Hub résolue comme la barre — `draco_config.app_url("hub")`)
- [x] Refus « aucun » dans `draco_session.user_for_draco` + réponse explicite de `require_auth`
- [x] Garde `require_admin_hub` sur les routes `/api/auth/admin/*`
- [x] Tests `tests/test_hub_roles.py` (client + intégration du chemin draco)
### 226. Hub HUB-13 — API d'administration des comptes (machine-à-machine)
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-05 | **Statut :** ✅ livré en 2.84.0 (PR en attente de fusion — voir CHANGELOG ; redéploiement Docker requis)
- **Fiche :** [features/hub-api-admin-comptes-226.md](./features/hub-api-admin-comptes-226.md)
- **Description :** carte fusion #127 (plan Hub v2 §2.2, D1(a)/D2(a)) : le Hub
administre les comptes dont ObsiGate reste le magasin, via
`/api/admin/machine/comptes` (liste, profil étendu, création,
modification, désactivation/réactivation avec révocation des `jti`
draco, suppression, réinitialisation du mot de passe avec changement
forcé `must_change_password`, réinitialisation du MFA), protégée par
la clé machine dédiée `DRACO_ADMIN_MACHINE_KEY` — jamais par une
session. Chaque action est journalisée dans `data/audit.log`.
- **Sous-tâches :**
- [x] Routes machine lecture/écriture + garde par clé dédiée (`backend/routers/comptes_machine.py`)
- [x] Registre des émissions draco + `revoke_all_for_user` (`backend/draco_session.py`)
- [x] Champ `must_change_password` (modèle, connexion, `/me`, changement de mot de passe)
- [x] Journalisation `log_compte_admin` (`backend/audit.py`) + tests `tests/test_comptes_machine.py`
### 225. Fusion — panneau Spark : version du service d'agents visible en mode sidebar
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-26 | **Statut :** ✅ livré le 2026-10-26 (version du hook — voir CHANGELOG ; redéploiement Docker requis)
- **Fiche :** [features/fusion-panneau-version-agent-225.md](./features/fusion-panneau-version-agent-225.md)
- **Description :** demande de Bruno (2026-10-26, carte fusion #64) : la
version « Service d'agents vX », déjà visible dans la console pleine
page, doit se lire aussi dans le **panneau natif des apps** (mode
sidebar). Le pied du panneau Spark affiche désormais cette ligne,
discrète et permanente, lue **en direct** dans la santé du service
(`GET /healthz` de l'origine injectée par l'hôte) à l'ouverture du
panneau, avec cache de session — jamais une valeur codée en dur ;
service injoignable = rien d'affiché. Même livraison : TikTak
0.61.3, Flowdeck 7.83.1, Kanbante 0.37.1.
- **Sous-tâches :**
- [x] Ligne de version dans le pied du panneau (canonique TikTak, propagée aux 4 apps)
- [x] Test `tests/test_draco_barre.py::test_panneau_version_service_affichee_225` + épreuve comportementale jsdom sur les 4 copies
- [x] Release publiée — CI verte à constater, redéploiement Docker (geste de Bruno)
### 224. Sécurité des dépendances — pip-audit vierge (brotli, filelock, opentelemetry-api, pypdf)
- **Effort :** ~0,1 jour | **Impact :** 🔴 | **Ouvert :** 2026-10-25 | **Statut :** ✅ livré le 2026-10-25 (version du hook — voir CHANGELOG ; redéploiement Docker requis)
- **Fiche :** [features/securite-dependances-pip-audit-224.md](./features/securite-dependances-pip-audit-224.md)
- **Description :** le job `security` du run CI 2303 (2.78.0) échoue
sur `pip-audit` : 17 vulnérabilités dans 4 paquets hérités de
l'environnement du runner (toolcache « already satisfied », même
piège que BUG-093). Planchers explicites dans
`backend/requirements.txt` : pypdf relevé à `>=6.21.2`, nouveaux
planchers `brotli>=1.2.1`, `filelock>=4.2.1`,
`opentelemetry-api>=1.45.1` ; garde-fou `TestDependencySecurityFloors`
étendu. Aucun code applicatif, aucun nouvel ignore pip-audit.
- **Sous-tâches :**
- [x] Planchers de sécurité dans `backend/requirements.txt` + commentaires
- [x] Garde-fou `tests/test_ci_workflow.py::TestDependencySecurityFloors` étendu (pypdf 6.21.2, brotli, filelock, opentelemetry-api)
- [x] `pip-audit` local : 0 vulnérabilité non ignorée (mêmes ignores que la CI)
- [x] Release publiée — CI verte à constater, redéploiement Docker (geste de Bruno)
### 223. Dev/prod sans noms publics en dur — URLs de l'écosystème par configuration
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-25 | **Statut :** ✅ livré le 2026-10-25 (version du hook — voir CHANGELOG ; redéploiement Docker requis, et en dev déclarer les noms en `-dev` dans le `.env` du poste)
- **Fiche :** [features/devprod-urls-config-223.md](./features/devprod-urls-config-223.md)
- **Description :** aucun nom public de l'écosystème dracodev ne doit être
figé dans le code vivant : les URL publiques (service d'agents, autres
apps) viennent de la configuration (défauts = noms **officiels** sans
suffixe ; l'environnement dev déclare ses noms en `-dev` dans son
`.env`). CSP dérivée de l'origine de l'agent configurée, panneau Spark
et liens inter-apps alimentés par l'hôte (injection `window.DRACO_URLS`
au service de `index.html`), références croisées reconstruites depuis
la config à l'affichage (aucune donnée migrée).
- **Sous-tâches :**
- [x] Module `backend/draco_config.py` + `.env.example`
- [x] CSP `connect-src`/`frame-src` dérivée de la config (tests via config)
- [x] `external_refs` : hôtes reconnus + URL d'affichage construites par config
- [x] Frontend : panneau Spark, liens TikTak, onglets des apps via `DRACO_URLS`
- [x] WebAuthn : confirmation rp_id/origines dérivés de la requête / `OBSIGATE_WEBAUTHN_*`
### 222. Fusion — panneau Spark V3 : la console en surimpression pleine page
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-23 | **Statut :** ✅ livré le 2026-10-23 (2.77.0, redéploiement Docker en attente)
- **Fiche :** [features/fusion-panneau-console-surimpression-222.md](./features/fusion-panneau-console-surimpression-222.md)
- **Description :** le panneau Spark (asset vendangé `frontend/draco-panneau.js`,
octet-identique dans les 4 apps) passe en V3 (demande de Bruno,
2026-10-22) : le bouton « Console ⤢ » n'ouvre plus la console du
service d'agents en nouvel onglet (#221) mais en **surimpression
pleine page dans la même page** — montée à la largeur du panneau,
étendue à toute la fenêtre par transition, iframe gardant le lien
profond (`?disc=` / `?app=`), et deux sorties dans sa barre :
« Mode sidebar » (rétraction vers le panneau) et « Fermer l'agent ».
Styles injectés par le panneau ; côté service, la console autorise
l'encadrement par les 4 apps (0.20.0). Même livraison : TikTak
0.60.0, Flowdeck 7.81.0, Kanbante 0.29.0.
- **Sous-tâches :**
- [x] Surimpression + barre « Mode sidebar » / « Fermer l'agent »
dans le panneau canonique (TikTak), recopié octet-identique
dans les 4 apps
- [x] Test `tests/test_draco_barre.py::test_panneau_console_surimpression_222`
(et révision du test #221 : le nouvel onglet disparaît)
- [x] Release v2.77.0 publiée — CI à constater, redéploiement Docker (geste de Bruno)
### 221. Fusion — panneau Spark : bouton « Console ↗ » vers la console du service
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** 🔵 en cours
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.76.1, redéploiement Docker en attente)
- **Fiche :** [features/fusion-panneau-console-221.md](./features/fusion-panneau-console-221.md)
- **Description :** le panneau Spark (asset vendangé `frontend/draco-panneau.js`,
octet-identique dans les 4 apps) gagne un bouton « Console ↗ » dans sa barre
@@ -47,7 +183,7 @@
- [x] Bouton + `ouvrirConsole()` / `appHote()` dans le panneau canonique
(TikTak), recopié octet-identique dans les 4 apps
- [x] Test `tests/test_draco_barre.py::test_panneau_bouton_console_221`
- [ ] CI verte + redéploiement Docker (geste de Bruno)
- [x] Release v2.76.1 publiée — CI à constater, redéploiement Docker (geste de Bruno)
### 220. Fusion — émission d'événements vers le journal du service d'agents (Lot 2, vague 5)
@@ -1088,6 +1224,11 @@
| 166 | Assistant IA — détection & fusion de doublons (score déterministe, paires candidates, fusion backup + confirmation) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
| 168 | Assistant IA — notifications externes Discord / Telegram / SMTP / webhook (déclencheurs, secrets hors config, SSRF-safe) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
| 170 | Assistant IA — tâches planifiées type cron (create/append/notify, interval/daily/once, tick 60 s, échec notifié) | Unreleased | [features/agent-phase4-166-168-170.md](./features/agent-phase4-166-168-170.md) |
| BUG-119 | Panneau Spark — « mode console » bloqué par la CSP (`frame-src` sans l'origine du service d'agents) | 2.77.1 | [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) |
| BUG-120 | Menu inter-apps — les URL sœurs (`window.DRACO_URLS`) dérivent l'environnement de l'hôte de l'agent (`-dev` en dev), explicite prioritaire, Gitea inchangée (suite de #223) | 2.78.2 | [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) |
| 225 | Panneau Spark — version du service d'agents visible en mode sidebar (pied du panneau, lue en direct via `GET /healthz`) | 2.79.0 | [features/fusion-panneau-version-agent-225.md](./features/fusion-panneau-version-agent-225.md) |
| BUG-123 | Aller-retour SSO — le `return_to` de `/sso/authorize` est honoré aussi après restauration de session au démarrage (`initAuth`), pas seulement après un login frais | Unreleased | [features/entree-sso-bug-123-124.md](./features/entree-sso-bug-123-124.md) |
| BUG-124 | Barre commune v0.7.0 — `DRACO_BAR.setUser` branché dans `renderUserSection()` : le nom du compte s'affiche dans le menu compte dès le login, sans rechargement | Unreleased | [features/entree-sso-bug-123-124.md](./features/entree-sso-bug-123-124.md) |
---
@@ -0,0 +1,59 @@
# BUG-122 — Déconnexion globale navigable : `GET /sso/logout`
**Carte :** fusion #141 (signalement de Bruno, 2026-11-06, capture
depuis la console Spark). **Version :** 2.86.0. **Prérequis :** ACC-3.1
(#216) — la déconnexion globale existe déjà dans
`POST /api/auth/logout`.
## Constat
Le bouton « Déconnexion » du menu compte de la barre commune
(dracodev-commons) est une **action relayée à l'hôte** (`cfg.onAction`,
sinon événement `draco:bar`) : la barre ne déconnecte jamais
elle-même. Or :
- console Spark, ObsiGate, Flowdeck, TikTak, ShaarIt et Hub ne
branchaient **aucune** action `deconnexion` (clic sans effet,
reproduit en navigateur réel sur la console : session draco intacte
après le clic) ;
- Kanbante, seul branché, ne détruisait que sa session locale — la
session SSO survivait ;
- et de toute façon, aucun hôte n'aurait pu terminer la session
globale : `POST /api/auth/logout` n'est appelable qu'en same-origin
depuis l'interface d'ObsiGate. Il manquait le point d'entrée
navigable, symétrique de `GET /sso/authorize`.
## Livraison (cette PR, côté émettrice)
| Méthode | Chemin | Effet |
|---|---|---|
| GET | `/sso/logout?return_to=<url>` | Déconnexion globale, puis redirection 302 vers le `return_to` validé (repli « / ») |
- La logique de `POST /api/auth/logout` est extraite dans
`apply_global_logout(request, response)` (`backend/auth/router.py`)
et partagée par les deux entrées : révocation des jetons natifs
refresh/access, révocation du `jti` du cookie `draco_session`,
effacement des cookies (dont `draco_session` sur le domaine parent).
- `return_to` passe par la liste blanche existante
(`draco_session.validate_return_to` : https + `dracodev.net` ou
sous-domaine) — aucun relais ouvert.
- Idempotente : sans session, la route redirige quand même (les hôtes
y naviguent même si leur session a déjà expiré).
- La barre d'ObsiGate elle-même branche son action `deconnexion` sur
cette route (`frontend/index.html`, retour à sa racine) — garde :
`tests/test_draco_barre.py::test_barre_deconnexion_branchee`.
## Côté hôtes (PR sœurs, carte fusion #141)
Chaque hôte branche son action `deconnexion` : destruction de sa
session locale d'abord (Flowdeck, TikTak, Kanbante, ShaarIt), puis
navigation vers `<émettrice>/sso/logout?return_to=<sa base>`. La
console et le Hub (session draco seule) naviguent directement.
Après le parcours complet, « Déconnexion » termine **la session de la
suite entière** et la session locale de l'hôte courant.
## Tests
`tests/test_draco_session.py` (+3) : révocation du `jti` + redirection
vers le `return_to` + cookie effacé (`Max-Age=0`), liste blanche
(relais refusé, repli « / »), idempotence sans session.
+97
View File
@@ -0,0 +1,97 @@
# #223 — Dev/prod sans noms publics en dur : URLs de l'écosystème par configuration
> Ouvert le 2026-10-25 (chantier dev/prod de Bruno). Contexte : l'installation
> actuelle (`deepin.lab.home`) devient l'environnement **dev** (noms en `-dev`),
> la future installation (`jump.point.mint.home`) sera la **prod** (noms
> officiels sans suffixe), identique sauf noms et clés. Règle : aucun nom
> public de l'écosystème codé en dur dans le code vivant ; défauts = noms
> officiels ; le dev déclare ses `-dev` dans son seul `.env`.
## Configuration (nouveau module `backend/draco_config.py`)
Lecture à l'appel, jamais à l'import. Défauts officiels :
| Variable | Défaut officiel | Rôle |
|---|---|---|
| `DRACO_AGENT_URL` | `https://agent.dracodev.net` | Base du service d'agents. À défaut : origine dérivée d'`AGENT_EVENEMENTS_URL`, puis défaut. |
| `DRACO_OBSIGATE_URL` | `https://og.dracodev.net` | Liens profonds ObsiGate |
| `DRACO_FLOWDECK_URL` | `https://flowdeck.dracodev.net` | Liens profonds Flowdeck |
| `DRACO_TIKTAK_URL` | `https://tiktak.dracodev.net` | Liens profonds TikTak |
| `DRACO_KANBANTE_URL` | `https://kanbante.dracodev.net` | Onglet Kanbante de la barre |
| `DRACO_GITEA_URL` | `https://git.dracodev.net` | Cartes Kanbante (= issues Gitea). Distincte d'`OBSIGATE_GITEA_URL` (source connectée de l'assistant). |
`AGENT_EVENEMENTS_URL` (existant, #220) reste la configuration première du
journal d'événements ; `agent_journal._url()` retombe sur
`DRACO_AGENT_URL + /api/v1/evenements` quand elle est absente.
## Backend
- **CSP** (`backend/main.py`, `SecurityHeadersMiddleware`) : `connect-src` et
`frame-src` utilisent `draco_config.agent_origine()` — dérivée de la config
à chaque réponse, plus aucun littéral. Tests dans
`tests/test_security_headers.py` **via la config** (défaut officiel neutralisé,
dérivation depuis `AGENT_EVENEMENTS_URL`, priorité `DRACO_AGENT_URL`).
- **Références croisées** (`backend/external_refs.py`) :
- `parse_deep_link` reconnaît les hôtes officiels **et** configurés
(`draco_config.hotes_reconnus()`) — un lien collé depuis le dev comme
depuis la prod est reconnu.
- `build_deep_link(app, type, id)` construit l'URL d'affichage depuis la
config ; `list_refs` / `get_ref` / `delete_ref` servent des copies dont
`target_url` est **reconstruite à la lecture**. ⚠️ Les références
**stockées** (`data/external_refs/*.json`) gardent l'URL absolue collée
par l'utilisateur : aucune migration, la valeur stockée n'est simplement
plus jamais servie telle quelle.
- **WebAuthn — confirmé, aucun changement** : `resolve_relying_party()`
(`backend/auth/webauthn_mfa.py`, BUG-070) dérive `rp_id`/origines de la
requête (hôte exact, `X-Forwarded-Host`/`X-Forwarded-Proto` honorés si
`OBSIGATE_TRUST_PROXY=true`), et `OBSIGATE_WEBAUTHN_RP_ID` /
`OBSIGATE_WEBAUTHN_ORIGINS` priment quand ils sont posés. Aucun nom figé.
## Frontend (contrat d'injection de dracodev-commons)
L'hôte fournit les URL : `backend/main.py::_index_html()` injecte
`window.DRACO_URLS = {…}` (JSON issu de `draco_config.urls_publiques()`,
nonce CSP par le mécanisme existant) dans `index.html` au service.
- `frontend/draco-panneau.js` : `API = window.DRACO_URLS.agent || défaut officiel` ;
`appHote()` retire un suffixe `-dev` éventuel du premier label d'hôte
(`og-dev` → `obsigate` pour le lien profond `?app=`).
- `frontend/draco-liens.js` : « Ouvrir dans TikTak » =
`window.DRACO_URLS.tiktak || défaut officiel`.
- `frontend/index.html` : les `href` des onglets portent les défauts officiels ;
le script de configuration de la barre les réécrit depuis `DRACO_URLS`.
Le menu des apps (`draco-barre.js`) est construit depuis ces onglets : il
suit donc sans changement.
- Chemin réellement servi : `/static/draco-*.js` via le montage `StaticFiles`,
`Cache-Control: no-cache` posé par `SecurityHeadersMiddleware` sur
`/static/*`, et service worker **network-first** pour le code — aucun
cache-busting par empreinte n'existe (revalidation systématique).
## Correctif BUG-120 (2026-10-25) — dérivation d'environnement des sœurs
Constat sur la dev : le `.env` du poste ne déclare que l'agent (via
`AGENT_EVENEMENTS_URL`) ; les quatre `DRACO_<APP>_URL` absentes
retombaient sur les défauts prod dans `window.DRACO_URLS` — le menu
inter-apps pointait la prod depuis la dev. La résolution d'une URL
sœur (`backend/draco_config.py::app_url`) gagne une étape
intermédiaire, entre l'explicite et le défaut officiel :
**dérivation depuis l'hôte de l'agent** (`agent_url()`) — hôte de la
forme `agent<SUFFIXE>.dracodev.net` sans port → sœur =
`https://<slug><SUFFIXE>.dracodev.net` (slugs : `og`, `flowdeck`,
`tiktak`, `kanbante`). Hôte hors motif (autre domaine, LAN, IP,
port) → aucune dérivation. Gitea exclue. Le dev n'a plus quatre
variables à déclarer ; un `.env` qui les déclare déjà est inchangé
(l'explicite prime).
## Restes assumés
- Suffixe SSO `dracodev.net` (`draco_session.validate_return_to`,
`frontend/js/auth.js`) : ce n'est pas un nom d'hôte mais le domaine parent
commun aux deux environnements (les noms `-dev` restent des sous-domaines) ;
le domaine du cookie est déjà configurable (`DRACO_SESSION_COOKIE_DOMAIN`).
- Mentions textuelles (titres/messages du panneau, contact OpenAPI et lien
« À propos » vers le dépôt Git) : infra Git hors périmètre, ou libellés.
- `OBSIGATE_SEARXNG_URL` : déjà configurable, défaut officiel conservé.
- Le panneau reste un asset partagé : la même ligne d'injection doit être
recopiée dans les 3 autres apps par leurs chantiers respectifs pour
retrouver l'octet-identique.
+77
View File
@@ -0,0 +1,77 @@
# Entrée SSO de bout en bout — BUG-123 + BUG-124
Chantier « entrée SSO de bout en bout » (écarts constatés par Bruno en
test réel le 2026-11-08, compte « testeur » créé au Hub) : deux des
trois écarts tenaient à ObsiGate. Diagnostic mené en navigateur réel
dans une pile locale complète servie sous les noms publics
(`*-dev.dracodev.net`, TLS local), chaîne d'URL et de cookies
journalisée à chaque saut — dossier de preuves `preuve-entree-sso`.
## BUG-123 — L'aller-retour SSO ne se referme pas (session existante)
**Chaîne observée (cas fautif)** : clic « Se connecter avec
ObsiGate (SSO) » dans l'application sœur → `GET
/sso/authorize?return_to=<app>/auth/draco` → `302 /?return_to=…` →
la SPA restaure la session (`initAuth` : jeton encore valide, ou
refresh silencieux via le cookie HttpOnly) et affiche l'accueil —
**le `return_to` n'est jamais suivi**. L'utilisateur reste chez
ObsiGate ; revenu manuellement à l'application, il n'y trouve
aucune session locale (« toujours pas connecté »).
**Cause racine** : le retour n'était honoré qu'à un seul endroit,
`AuthManager.saveToken()` → `_honorReturnTo()` — c'est-à-dire après
un login frais, une MFA ou un WebAuthn. Les deux chemins de
restauration d'`initAuth()` ne passaient ni par `saveToken` ni par
`_honorReturnTo`. Le sous-cas « login frais » refermait la boucle
correctement (vérifié dans la même pile) — d'où un bug invisible
pour qui teste en navigateur vierge sans session centrale.
**Correctif** (`frontend/js/auth.js`) :
- `_honorReturnTo()` retourne `true` quand il redirige (même liste
blanche inchangée : https + `*.dracodev.net`, jamais l'hôte
courant — re-validation cliente en plus de celle du serveur).
- `initAuth()` invoque `_honorReturnTo()` sur ses deux chemins de
restauration réussie, avant `showApp()`.
- Les refresh silencieux **en cours de session** (`refreshAccessToken`
appelé par l'intercepteur) n'honorent toujours pas le retour :
une page déjà ouverte ne doit jamais être redirigée.
## BUG-124 — Le nom du compte absent du menu de la barre après login
**Constat** : juste après le login, le menu compte de la barre
commune affiche l'en-tête générique « Compte » et l'avatar neutre
« ✦ » ; un rechargement forcé fait apparaître le nom.
**Cause racine** : la barre résout le nom dans `cfg.user`, sinon
dans le `title` de l'avatar. Chez ObsiGate, le seul poseur du
`title` était le fetch d'amorçage d'`index.html` au
`DOMContentLoaded` — limité aux sessions **déjà présentes au
chargement**. `renderUserSection()` (`js/auth.js`), appelée pour
toutes les sessions établies (login, restauration), mettait à jour
le bloc latéral et le drapeau admin de la barre (`setAdmin`,
v0.6.0/fusion #142) mais jamais le nom : la barre n'avait aucune
API pour le recevoir.
**Correctif** :
- dracodev-commons **v0.7.0** (PR #15) : API publique
`DRACO_BAR.setUser(user)` — même contrat de valeur que `cfg.user`
(chaîne, ou objet `display_name` / `name` / `username`) ;
resynchronise l'avatar (initiale, infobulle, aria-label) et
l'en-tête du menu compte immédiatement ; `setUser(null)` rétablit
l'état neutre. Barre re-vendangée verbatim dans
`frontend/draco-barre.js`.
- `renderUserSection()` appelle `setUser(name)` pour toute session
établie et `setUser(null)` sans identité ; le fetch d'amorçage
d'`index.html` pousse aussi le nom par `setUser` (le `title` seul
ne resynchronisait ni l'aria-label ni un menu déjà construit).
## Vérifications
- `tests/test_draco_barre.py::test_barre_v070_setuser_et_retour_sso_restauration`
(contrats épinglés) + suites draco/session/version vertes.
- `node ui/barre-apps/tests/barre-apps.test.js` côté commons :
58 tests, 284 assertions (nouveau test setUser).
- Preuve navigateur de bout en bout (scénario de Bruno, contexte
vierge) : voir le rapport du chantier et `preuve-entree-sso/`.
@@ -0,0 +1,52 @@
# #222 — Fusion : panneau Spark V3, la console en surimpression pleine page
**Contexte.** Le bouton « Console ↗ » du panneau Spark (#221) ouvrait
la console web du service d'agents (https://agent.dracodev.net/)
dans un nouvel onglet : on quittait l'application en cours pour un
autre onglet. Demande de Bruno (2026-10-22) : rester dans la même
page — la console s'ouvre par-dessus l'application, avec une
animation comme celle de la barre latérale mais qui recouvre la
page au complet, et deux sorties explicites : revenir en mode
sidebar, ou fermer l'agent.
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
maintenu en canonique dans TikTak et recopié **octet-identique** dans
les 4 applications (TikTak, Flowdeck, Kanbante, ObsiGate) :
- le bouton devient « Console ⤢ » (même emplacement, barre des
discussions) et `ouvrirConsole()` n'ouvre plus d'onglet : il
monte une **surimpression** `.draco-console-sur` ancrée à droite,
née à la largeur du panneau, qui **s'étend à 100 % de la fenêtre**
par transition CSS (double `requestAnimationFrame` pour laisser
peindre la géométrie de départ) ;
- la console est chargée dans une **iframe** dont l'URL reste
construite au premier clic par `urlConsole()` : discussion
affichée (`?disc=<id>`) et app d'origine (`?app=obsigate`, déduite
du sous-domaine `og`) — le lien profond de #221 est conservé ;
- la barre de la surimpression (« ✦ Console Spark ») porte les deux
sorties demandées : **« ◧ Mode sidebar »** (`retrecirConsole()` :
l'overlay se rétracte à la largeur du panneau puis se masque,
l'iframe reste chargée) et **« ✕ Fermer l'agent »** (`fermer()` +
rétraction : panneau et console fermés) ;
- les styles de la surimpression sont **injectés par le panneau
lui-même** (`<style id="draco-console-styles">`, variables du
thème du panneau) : aucun fichier CSS à toucher côté hôte, et la
surimpression suit le thème de l'app comme le panneau.
Côté service d'agents (0.20.0) : la console autorise désormais
l'encadrement par les 4 applications (`frame-ancestors` restreint
aux sous-domaines `dracodev.net` des apps) — sans quoi l'iframe
resterait blanche.
**Vérifications.** Syntaxe JS (`node --check`) ; test
`tests/test_draco_barre.py::test_panneau_console_surimpression_222`
(marqueurs de la surimpression, des deux sorties et de l'animation
dans l'asset) ; le test #221 est révisé en conséquence (le lien
profond `?disc=` / `?app=` subsiste, l'ouverture en nouvel onglet
disparaît) ; somme md5 commune des copies :
`93026ffa14bc8ee896349e3858e12edf`.
**Versions.** ObsiGate 2.77.0 (cette livraison) ; TikTak 0.60.0 et
Flowdeck 7.81.0 déployées automatiquement sur release ; Kanbante
0.29.0 publiée. **Activation côté ObsiGate : redéploiement Docker
par Bruno.**
@@ -0,0 +1,40 @@
# #225 — Fusion : panneau Spark, version du service d'agents visible en mode sidebar
**Contexte.** La version « Service d'agents vX » du service d'agents
est visible dans la console pleine page (pilule d'en-tête, rendue par
le service lui-même), mais pas dans le **panneau natif des
applications** (mode sidebar) — or c'est là que Bruno vit au
quotidien. Demande de Bruno (2026-10-26), carte fusion #64 : la
version doit se lire dans le panneau ouvert, sans ouvrir la console.
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
maintenu en canonique dans TikTak et recopié dans les 4 applications
(TikTak, Flowdeck, Kanbante, ObsiGate ; chaque copie garde son bloc
d'injection d'origine propre à l'hôte, le corps est commun) :
- le pied du panneau gagne une ligne dédiée
`.draco-panneau-version`, discrète (11,5 px, opacité 0,6,
monochrome, aucun emoji), **masquée tant qu'aucune version n'est
connue** ;
- à l'ouverture du panneau, `chargerVersion()` interroge la santé
du service — `GET /healthz` de l'origine que le panneau connaît
déjà (celle injectée par l'hôte, ici `window.DRACO_URLS.agent`) —
et affiche « Service d'agents vX » depuis le champ `version` de
la réponse (la même source que la pilule de la console) ;
- cache de session de 5 minutes, rafraîchissement au rythme des
ouvertures du panneau (comme la vérification d'identité) ;
- **jamais de valeur codée en dur** : réponse illisible ou service
injoignable = la ligne reste masquée — pas de fausse version, pas
d'erreur visible.
**Preuve.** Épreuve comportementale (jsdom) exécutée sur les 4
copies avec le contrat d'injection de chaque hôte : santé joignable
→ la ligne affiche « Service d'agents v0.38.0 » (version lue sur
l'origine de l'agent) ; santé injoignable → rien d'affiché ; hôte
sans injection → aucun appel. Test de garde :
`tests/test_draco_barre.py::test_panneau_version_service_affichee_225`.
**Même livraison** (carte fusion #64) : TikTak 0.61.3, Flowdeck
7.83.1, Kanbante 0.37.1. Côté ObsiGate, la prise d'effet attend le
**redéploiement Docker** (geste de Bruno), comme les items panneau
précédents.
+105
View File
@@ -0,0 +1,105 @@
# #226 — HUB-13 : API d'administration des comptes (machine-à-machine)
**Carte :** fusion #127 (HUB-13, plan Hub v2 §2.2 et §4 vague A ; décisions
D1(a) — magasin des comptes chez ObsiGate — et D2(a) — ObsiGate émettrice).
**Version :** 2.84.0.
## Objet
Le Hub administre les comptes de la suite depuis son interface
d'administration ; ObsiGate reste le magasin des comptes. Cette API est
le bras armé de cette interface : administration complète des comptes,
en lecture et en écriture, réservée aux appels machine-à-machine.
## Routes — `/api/admin/machine/comptes`
Emplacement choisi : l'espace d'administration existant (`/api/admin/…`),
segment `machine` explicite. Il distingue ces routes de l'administration
par session (`/api/auth/admin/users`, garde `require_admin`) : aucune
session utilisateur, même administratrice, ne les ouvre.
| Méthode | Chemin | Effet |
|---|---|---|
| GET | `/api/admin/machine/comptes` | Liste des comptes (profils étendus) |
| GET | `/api/admin/machine/comptes/{username}` | Profil étendu d'un compte |
| POST | `/api/admin/machine/comptes` | Création (201 ; 409 si identifiant pris) |
| PATCH | `/api/admin/machine/comptes/{username}` | Modification (nom affiché, rôle global, langue, vaults) |
| POST | `/api/admin/machine/comptes/{username}/desactiver` | Désactivation + révocation des sessions |
| POST | `/api/admin/machine/comptes/{username}/reactiver` | Réactivation |
| DELETE | `/api/admin/machine/comptes/{username}` | Suppression + révocation des sessions |
| POST | `/api/admin/machine/comptes/{username}/reinitialiser-mot-de-passe` | Nouveau mot de passe temporaire, changement forcé |
| POST | `/api/admin/machine/comptes/{username}/reinitialiser-mfa` | Effacement de l'enrôlement TOTP |
Codes : 401 sans clé machine valide, 404 compte inconnu (ou API non
configurée), 409 conflit d'identifiant, 422 corps invalide.
## Protection
- Clé machine **dédiée** : `DRACO_ADMIN_MACHINE_KEY`, en-tête
`X-Draco-Machine-Key` (même famille et même en-tête que
`GET /api/sso/revocations`). Distincte de `DRACO_SSO_MACHINE_KEY` :
l'écriture de comptes est plus sensible que la lecture des
révocations (plan §3). Comparaison à temps constant.
- Sans la variable, l'API est inerte (404 « Non configuré »).
- Acteur consigné : en-tête facultatif `X-Draco-Actor` (défaut
`service-machine`).
## Profil étendu
Liste blanche stricte : `id`, `username`, `display_name`, `language`,
`avatar` (valeur du modèle — data-URL ou `null`, comme
`GET /api/auth/me`) et `avatar_present`, `role`, `active`, `vaults`,
`mfa_enabled` (oui/non), `mfa_method`, `must_change_password`,
`created_at`, `last_login`. **Jamais** le haché du mot de passe, le
secret TOTP, les codes de récupération ni les identifiants WebAuthn.
## Désactivation et sessions
- `active = false` → la connexion est refusée (401 uniforme, BUG-039)
et plus aucun jeton n'est émis.
- Les `jti` draco émis pour le compte sont révoqués via le mécanisme
existant (`draco_session.revoke`, liste publiée par
`GET /api/sso/revocations`). Pour les connaître, `draco_session`
tient désormais un registre des émissions (`data/draco_issued.json`,
`jti → exp` par `sub`, purgé à l'expiration) et
`revoke_all_for_user(sub)`.
- Le repère `password_changed_at` (BUG-028) est avancé : les jetons
natifs déjà émis sont invalidés et ne ressuscitent pas à la
réactivation. Les anciennes sessions draco restent révoquées après
réactivation : une nouvelle connexion est nécessaire.
## Changement forcé du mot de passe
Nouveau champ du modèle utilisateur : `must_change_password` (booléen,
défaut `false` — ajouté par `create_user`, aucun fichier à migrer :
les comptes existants sans le champ valent `false`). Posé à la
création machine et à la réinitialisation du mot de passe ; signalé
dans la réponse de connexion et `GET /api/auth/me` ; effacé par
`POST /api/auth/change-password`.
## Réinitialisation du MFA
Efface `mfa_secret`, `mfa_secret_pending` et `mfa_recovery_codes`.
Les clés WebAuthn éventuelles sont conservées et gardent le MFA
actif (méthode `webauthn`) ; sans elles, `mfa_enabled` passe à `false`.
## Journalisation
Chaque action est consignée dans le journal d'audit existant
(`data/audit.log`, JSON lignes) via `backend.audit.log_compte_admin` :
horodatage, `acteur` (identité de service), `action` (`compte_cree`,
`compte_modifie`, `compte_desactive`, `compte_reactive`,
`compte_supprime`, `mot_de_passe_reinitialise`, `mfa_reinitialise`),
`cible` (compte visé), IP et détails non sensibles (champs modifiés,
nombre de `jti` révoqués — jamais de mot de passe ni de secret).
## Tests
`tests/test_comptes_machine.py` : refus sans clé / mauvaise clé / clé
SSO / session admin, API inerte non configurée, liste et profil étendu
sans secret, création puis lecture et connexion, doublon 409,
modification, désactivation (connexion refusée, session native
refusée, `jti` draco révoqué et publié) puis réactivation,
réinitialisation du mot de passe (ancien refusé, nouveau accepté,
changement forcé signalé puis effacé au changement), réinitialisation
MFA (connexion directe après), suppression, journalisation.
+77
View File
@@ -0,0 +1,77 @@
# #228 — HUB-19 : propagation des rôles du Hub à ObsiGate
**Carte :** suite de HUB-19 (plan Hub v2 §2.2, décision D3(a)) — le pilote
ShaarIt (0.7.0, en production) a posé le contrat ; ObsiGate le consomme à
son tour, comme hôte à la fois **émettrice** et **consommatrice** du SSO.
**Version cible :** 2.85.1 (branche depuis le main 2.84.1 ; hypothèse : à
rebaser après la PR « API sessions HUB-22 » — 2.85.0 — si elle fusionne
d'abord).
## Contrat consommé (D3(a))
À l'ouverture de session draco — chez ObsiGate : la validation locale du
cookie partagé, `draco_session.user_for_draco()` (ACC-3.2, #217), appelée
par le repli draco de `get_current_user` — consultation du registre :
- `GET {hub}/api/roles/obsigate`, en-tête **Cookie complet** de la requête
relayé tel quel, `Accept: application/json`, délai **2 s** ;
- réponse 200 : `{"app", "sub", "role", "explicite", "ttl_secondes": 60}`,
`role` ∈ {`aucun`, `utilisateur`, `administrateur`} ;
- **URL du Hub** : `draco_config.app_url("hub")`, la résolution de la
barre commune (#223) — `DRACO_HUB_URL` explicite d'abord, dérivation
par l'hôte sœur (suffixe d'environnement de l'hôte de l'agent)
ensuite, défaut officiel enfin. Jamais d'URL dev figée.
## Client — `backend/hub_roles.py`
Sur le patron du pilote (`src/hub_roles.rs` de ShaarIt) :
- cache mémoire par `sub`, TTL **60 s** ;
- horodatage posé **avant** l'appel : un échec conserve l'ancien cache
et ne déclenche pas de rafale vers un Hub en panne ;
- toute erreur (réseau, délai, statut ≠ 200, JSON illisible, rôle hors
contrat) → rôle **inconnu** (`None`) → l'appelant garde son
comportement actuel. Le registre ne crée jamais un verrouillage
nouveau.
## Effets (sessions établies par draco seulement)
1. **Niveau connu `aucun`** → `user_for_draco` refuse : pas de session
établie par draco. `require_auth` répond alors **403** avec un
message explicite en français (niveau « aucun », matrice des rôles
du Hub, accès natif toujours possible), consigné via
`request.state.role_hub_refuse`. L'**authentification native**
d'ObsiGate (son propre login — c'est l'émettrice, son accès propre
est le secours) n'est **jamais** touchée.
2. **Routes d'administration `/api/auth/admin/*`** (CRUD des comptes,
`backend/auth/router.py`) : nouvelle garde `require_admin_hub`
(`backend/auth/middleware.py`) = `require_admin` + , quand la
requête est authentifiée par le **canal draco** (`_draco_sub`
présent, `_hub_role` joint par `user_for_draco`) et que le rôle est
**connu**, exigence du niveau `administrateur` du registre.
Sessions natives → règles actuelles inchangées ; rôle inconnu →
règles actuelles inchangées. Le registre n'élève aucun privilège :
un compte local non admin reste refusé par la règle locale, quel
que soit son niveau au Hub.
3. **Inchangés** : validation locale du jeton, émission du cookie,
révocations (`jti`), renouvellement glissant. **Aucun rôle dans le
jeton** (claims minimaux de #216 préservés).
Périmètre assumé : la garde du registre vise les routes nommées par la
carte, `/api/auth/admin/*`. Les autres surfaces gardées par
`require_admin` (configuration, vaults, coffres…) gardent leurs règles
actuelles ; les y soumettre serait un changement d'une ligne par
surface (`require_admin_hub` est réutilisable tel quel) si Bruno le
décide.
## Tests
`tests/test_hub_roles.py` : parsing du contrat (URL, Cookie relayé,
`Accept`, délai 2 s), rôles reconnus / hors contrat, JSON illisible,
erreurs HTTP et réseau → inconnu ; résolution de l'URL du Hub
(explicite, dérivée `-dev`, défaut officiel) ; cache par `sub`, TTL,
échec sans rafale avec ancien cache conservé ; intégration sur le
chemin draco (refus « aucun » explicite, login natif intact sous
« aucun », rôle inconnu = comportement actuel, `utilisateur` Hub
refusé sur `/api/auth/admin/*`, `administrateur` Hub accepté, compte
local non admin non élevé, session native jamais bloquée par le Hub).
+85
View File
@@ -0,0 +1,85 @@
# #227 — HUB-22 : API machine des sessions draco (liste et révocation)
**Carte :** vague C du plan Hub v2.1 (HUB-22) — le Hub consomme ces
routes en 0.17.0 pour l'écran des sessions d'un utilisateur.
**Version :** 2.85.0. **Prérequis :** HUB-13 (#226) — registre des
émissions `jti` et garde machine.
## Objet
Le Hub doit lister les sessions actives d'un utilisateur et les
révoquer une à une ou toutes, via le canal `jti` livré en HUB-13.
Aucune route machine « sessions » n'existait : cette livraison la crée,
sous la même garde (`require_machine_admin`, clé
`DRACO_ADMIN_MACHINE_KEY`, en-têtes `X-Draco-Machine-Key` /
`X-Draco-Actor`) et avec la même journalisation d'audit que les routes
comptes existantes. Sans la clé configurée, l'API reste inerte (404) ;
sans clé valide, 401.
## Routes — `/api/admin/machine/comptes/{username}/sessions`
| Méthode | Chemin | Effet |
|---|---|---|
| GET | `/api/admin/machine/comptes/{username}/sessions` | Sessions actives du compte |
| POST | `/api/admin/machine/comptes/{username}/sessions/{jti}/revoquer` | Révocation d'une session |
| POST | `/api/admin/machine/comptes/{username}/sessions/revoquer-toutes` | Révocation de toutes les sessions |
Codes : 401 sans clé machine valide, 404 compte inconnu (ou API non
configurée), 404 `jti` absent de l'ensemble actif du compte.
### Liste — `GET …/sessions`
`200 {"username": …, "sessions": [{"jti", "exp", "expire_le",
"revoquee"}]}` — une entrée par `jti` émis et non expiré, triée par
`exp` croissant ; `expire_le` est l'échéance en ISO 8601 UTC et
`revoquee` signale un `jti` déjà présent dans la liste des
révocations.
### Révocation unitaire — `POST …/sessions/{jti}/revoquer`
`200 {"jti", "revoquee": true}`. Le `jti` n'est révoqué que s'il
appartient à l'ensemble émis du compte visé et n'est pas expiré :
**404 sinon, jamais de révocation cross-utilisateur**.
### Tout révoquer — `POST …/sessions/revoquer-toutes`
`200 {"revoquees": n}` — réutilise `revoke_all_for_user` de HUB-13.
C'est la « déconnexion globale », **distincte de la désactivation** :
le compte reste actif, une nouvelle connexion émet une nouvelle
session.
## Backend — `backend/draco_session.py`
- `issued_sessions_for_user(sub)` : sessions actives
(`exp > maintenant`) du `sub`, `[{jti, exp, revoquee}]` triées par
`exp` croissant ; les entrées expirées du `sub` sont purgées du
registre au passage.
- `revoke_one_for_user(sub, jti)` : révoque via le `revoke()`
existant uniquement si le `jti` appartient à l'ensemble émis de ce
`sub` et n'est pas expiré ; retourne un booléen.
La clé du registre est le même `sub` que celui de HUB-13
(`_identifiant_sessions` dans `comptes_machine.py` : `id` du compte,
à défaut `username`) — factorisée depuis `_revoquer_sessions`, qui
l'utilise aussi désormais.
## Journalisation
Les deux écritures sont consignées dans `data/audit.log` via
`log_compte_admin` : actions `session_revoquee` (détail : le `jti`) et
`sessions_revoquees` (détail : nombre de `jti` révoqués), acteur =
identité de service `X-Draco-Actor`. La lecture suit le patron des
lectures existantes (non journalisée).
## Tests
- `tests/test_draco_session.py` : liste triée et marquage `revoquee`,
purge des expirés, révocation unitaire, refus cross-utilisateur,
refus d'un `jti` expiré ; le registre des émissions est isolé en
fichier temporaire dans la fixture.
- `tests/test_comptes_machine.py` (`TestSessions`) : liste (contenu,
`expire_le` ISO 8601 UTC), liste vide, compte inconnu 404,
révocation unitaire (succès + effet sur `verify_token`),
cross-utilisateur 404 sans effet, tout-révoquer (compte toujours
actif, autres comptes intacts, seconde passe à 0), garde (401 sans
clé, 404 API non configurée), journalisation.
@@ -0,0 +1,94 @@
# #224 — Sécurité des dépendances : pip-audit vierge (brotli, filelock, opentelemetry-api, pypdf)
> Ouvert le 2026-10-25. Déclencheur : le job `security` du run CI 2303
> (commit `f29a228`, version 2.78.0) échoue sur `pip-audit` (bloquant,
> #87) — **17 vulnérabilités connues dans 4 paquets**. Le job `lint`
> du même run est vert ; `security` est le seul job terminé en échec
> au moment du constat (`test` en file, `build`/`e2e` en attente).
## Constat (journal du job `security`, run 2303)
| Paquet | Version auditée | Vulnérabilités | Corrigée en |
|---|---|---|---|
| brotli | 1.2.0 | PYSEC-2026-4322, GHSA-9v8h-qqgm-4m35 | 1.2.1 |
| filelock | 3.32.3 | CVE-2026-112940, CVE-2026-112913, CVE-2026-112907 | 4.2.1 |
| opentelemetry-api | 1.37.0 | CVE-2026-114603 | 1.45.1 |
| pypdf | 6.19.0 | CVE-2026-113792, CVE-2026-113795, CVE-2026-113793, CVE-2026-113794, CVE-2026-113786, CVE-2026-113791, CVE-2026-113790, CVE-2026-113789, CVE-2026-115572, CVE-2026-115585, CVE-2026-115580 | 6.20.0 → 6.21.2 selon l'advisory (max 6.21.2) |
Total : 2 + 3 + 1 + 11 = **17 vulnérabilités**.
Audit local de confirmation, en deux temps : (1) un venv épinglé sur
les versions du runner (brotli 1.2.0, filelock 3.32.3,
opentelemetry-api 1.37.0, pypdf 6.19.0) reproduit **exactement** les
17 vulnérabilités et les versions corrigées du tableau ci-dessus ;
(2) dans le venv du dépôt, `pip-audit` (avec les deux ignores déjà en
place dans la CI) ne ressortait avant correctif que brotli 1.2.0
(2 advisories, fix 1.2.1) et pypdf 6.21.1 (3 advisories restants,
fix 6.21.2) — ce venv a déjà filelock 4.2.4 (corrigée) et ne contient
pas opentelemetry-api. L'écart avec la CI vient de l'environnement
audité, pas des dépendances déclarées (voir cause).
## Cause racine
Même piège que BUG-093 / BUG-095, déjà documenté dans
`backend/requirements.txt` et `.gitea/workflows/ci.yml` : `pip-audit`
audite **tout l'environnement Python du runner**, dont les paquets
préinstallés dans la toolcache de l'image `catthehacker/ubuntu:act-latest`.
`pip install -r backend/requirements.txt` répond « already satisfied »
pour toute exigence transitive déjà satisfaite par la toolcache et
n'aligne jamais la version vulnérable :
- **pypdf 6.19.0** : préinstallée ; le plancher `>=6.16.1` (BUG-093)
est satisfait, pip ne monte pas vers 6.21.2.
- **brotli 1.2.0** : transitive de `fonttools[woff]` (via weasyprint),
exigée en `>=1.0.1` seulement.
- **filelock 3.32.3** : transitive de CacheControl (outil `pip-audit`
lui-même) et préinstallée ; exigée en `>=3.8.0` seulement.
- **opentelemetry-api 1.37.0** : préinstallée dans la toolcache,
aucune dépendance d'ObsiGate ne la demande.
## Correctif
Planchers de sécurité explicites dans `backend/requirements.txt`,
selon le patron déjà en place (pyjwt, urllib3, multidict, certifi) —
aucune autre dépendance touchée, aucun code applicatif modifié :
- `pypdf>=6.16.1` → **`pypdf>=6.21.2`** (plancher existant relevé) ;
- **`brotli>=1.2.1`** (nouveau plancher) ;
- **`filelock>=4.2.1`** (nouveau plancher) ;
- **`opentelemetry-api>=1.45.1`** (nouveau plancher).
Garde-fou : `tests/test_ci_workflow.py::TestDependencySecurityFloors`
couvre désormais aussi brotli, filelock et opentelemetry-api, et le
plancher verrouillé de pypdf passe à (6, 21, 2). Le commentaire de
l'étape Pip-audit de `.gitea/workflows/ci.yml` est mis à jour en
conséquence (aucune commande CI modifiée).
## Exceptions (ignores)
**Aucun nouvel ignore.** Les deux exceptions déjà en place dans la
CI restent inchangées et ne concernent pas ces paquets :
`PYSEC-2026-1325` (ecdsa, aucun correctif upstream, chemins ECDSA
jamais exécutés — HS256 uniquement) et `CVE-2026-85394`
(python-jose, aucun correctif upstream, non atteignable — voir le
commentaire de l'étape dans `ci.yml`).
## Vérification
- `pip-audit` local (mêmes ignores que la CI) après alignement de
l'environnement sur les nouveaux planchers : **0 vulnérabilité
non ignorée**.
- Portes locales du workflow : `pytest tests/`, `ruff check backend/`,
`mypy backend/ --ignore-missing-imports`, tests frontend
(`validate-imports` + `unit`) — voir le commit #224.
- CI Gitea complète verte exigée (lint, test, security, build, e2e)
avant de considérer #224 livré.
## Livraison
- Commit conventionnel `fix: … #224` ; le hook de version porte
2.78.0 → **2.78.1** (correctif) et crée le tag `v2.78.1`, publié
au push ; release Gitea créée sur ce tag après vérification de sa
cible.
- **Redéploiement Docker requis** pour que l'image embarque les
dépendances alignées (l'hôte actuel est l'environnement dev).
+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`.
+4 -4
View File
@@ -19,8 +19,8 @@
var mode = localStorage.getItem("obsigate-theme-mode") || "dark";
root.setAttribute("data-theme", mode);
var light = mode === "light" || mode === "sepia";
root.style.setProperty("--bg-primary", light ? "#ffffff" : "#0f1117");
root.style.setProperty("--text-primary", light ? "#1a1a1a" : "#e6edf3");
root.style.setProperty("--bg-primary", light ? "#fbfbfa" : "#17181a");
root.style.setProperty("--text-primary", light ? "#242624" : "#eceeeb");
} catch (e) { root.setAttribute("data-theme", "dark"); }
})();
</script>
@@ -41,8 +41,8 @@
min-height: 100vh;
overflow: auto; /* override SPA style.css: body{overflow:hidden} */
height: auto; /* override SPA style.css: body{height:100vh} */
background: var(--bg-primary, #0f1117);
color: var(--text-primary, #e6edf3);
background: var(--bg-primary, #17181a);
color: var(--text-primary, #eceeeb);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
}
+24
View File
@@ -1214,6 +1214,30 @@
function initTheme() {
const saved = localStorage.getItem("obsigate-theme") || "dark";
applyTheme(saved);
// Thème commun de la suite (HUB-18, fusion #132) : la barre
// commune (barre v0.5.0) applique la préférence du Hub
// (sombre/clair/système) à l'ouverture — directement sur
// data-theme, et via l'événement « draco:theme » pour que le
// mécanisme propre d'ObsiGate suive (icône et libellé du bouton,
// feuilles highlight.js). applyTheme mémorise le mode appliqué
// dans « obsigate-theme » : la préférence du Hub devient le
// choix local courant, et le bouton de bascule reste le maître
// pour la suite de la session (aucune ré-application de la barre
// après l'ouverture). « systeme » arrive déjà résolu en mode
// effectif (detail.mode) par la barre. Repli : si le Hub ne
// répond pas, aucun événement n'arrive et le choix local
// ci-dessus reste seul maître, comme avant.
document.addEventListener("draco:theme", (e) => {
const mode = e && e.detail && e.detail.mode;
if (mode === "dark" || mode === "light") applyTheme(mode);
});
// Si la barre s'est initialisée AVANT ce module (ordre des
// scripts defer/module selon la page), sa préférence déjà
// résolue depuis son cache est reprise ici.
const barre = window.DRACO_BAR;
if (barre && (barre.themeMode === "dark" || barre.themeMode === "light")) {
applyTheme(barre.themeMode);
}
}
function applyTheme(theme) {
+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);
+119 -19
View File
@@ -2,15 +2,44 @@
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.
v0.2.0 : nouvel onglet « ShaarIt » (data-app="shaarit", --app émeraude
#059669, logo logos/shaarit.svg via {{LOGO_SHAARIT}}) avant l'onglet
agent ; GARDE data-theme : les blocs prefers-color-scheme ne
s'appliquent que si <html> n'a AUCUN attribut data-theme
(html:not([data-theme])) — avant ce correctif, un hôte ayant choisi
le clair via data-theme="light" héritait quand même du sombre
système (piège constaté dans les copies vendangées de
Kanbante/Flowdeck ; les re-vendanges de la v0.2.0 le corrigent).
v0.3.0 (HUB-1) : aucun style nouveau — les liens du Hub du menu
compte (Télécharger, À Propos, Aides, Profil) réutilisent
.draco-menu-item (déjà ancre-compatible : text-decoration none)
et le séparateur .draco-menu-sep du menu des applications.
v0.4.0 (HUB-11) : .draco-menu-icone — les icônes monoline des
items du menu compte (SVG inline construit par le JS) : 18 px,
replacées à gauche du libellé par order: -1 (l'item est en
flex), couleur atténuée --ink-2, pleine --ink au survol/focus.
Jetons repris du prototype validé Console v7.2.
v0.4.1 (fusion #125) : menu compte resserré — min-width
248 → 232 px, le panneau épouse le contenu (demande de Bruno :
le menu était trop large une fois les icônes visibles).
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
ou prefers-color-scheme si l'app intégratrice ne force rien
(aucun attribut data-theme sur <html>). */
.draco-bar {
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
--agent: #c2437e; --danger: #cf4444;
--agent: #c00000; --danger: #cf4444;
position: relative;
height: 60px; box-sizing: border-box;
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
@@ -18,8 +47,12 @@
background: var(--surface); border-bottom: 1px solid var(--line);
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
}
/* Garde data-theme (v0.2.0) : le sombre système ne s'applique QUE si
* l'hôte n'a posé aucun data-theme sur <html>. Sans cette garde, un
* hôte en data-theme="light" sur un système sombre affichait la barre
* sombre malgré son choix explicite. */
@media (prefers-color-scheme: dark) {
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
html:not([data-theme]) .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
}
[data-theme="dark"] .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
@@ -32,7 +65,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) {
html:not([data-theme]) .draco-bar .draco-brand-mark img { filter: none; }
}
[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }
/* Menu des applications — construit par le JS depuis les onglets */
.draco-apps-menu {
@@ -42,6 +84,43 @@
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: 232px; 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); }
/* Item conditionnel masqué (v0.6.0 : « Administration » tant que
l'hôte n'a pas affirmé le statut d'administrateur) — le
display:flex ci-dessus écraserait l'attribut hidden sans cette
règle, même patron que .draco-compte-menu[hidden]. */
.draco-menu-item[hidden] { display: none; }
/* Icônes monoline du menu compte (v0.4.0) : appendues après le
libellé dans le DOM, replacées à gauche par order: -1. */
.draco-menu-icone {
order: -1; flex: none; display: inline-flex; align-items: center;
width: 18px; height: 18px; color: var(--ink-2);
}
.draco-menu-icone svg { display: block; width: 18px; height: 18px; }
.draco-menu-item:hover .draco-menu-icone,
.draco-menu-item:focus-visible .draco-menu-icone { color: var(--ink); }
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
.draco-compte-nom {
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
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 +145,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 +189,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 +213,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 +232,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 +240,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 +255,12 @@
.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"],
/* v0.2.0 : sur ShaarIt elle-même (data-current="shaarit"), c'est
l'onglet ShaarIt qui reste visible en mobile. */
.draco-bar[data-current="shaarit"] .draco-apptab[data-app="shaarit"],
/* Chantier 5 : sur la console elle-même (data-current="agent",
programme console #71), c'est l'onglet Console Spark qui reste
visible en mobile — les 4 hôtes gardent leur propre courant. */
.draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
}
+860 -42
View File
@@ -1,5 +1,74 @@
/* 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 ») ;
* v0.2.0 : 5e application ShaarIt — clé urls.shaarit, défaut officiel
* https://shaarit.dracodev.net/, dérivation -dev shaarit-dev (même
* contrat que les autres apps), entrée APP_LABELS ; l'onglet
* (data-app="shaarit", après Kanbante, avant agent) est repris
* automatiquement par la construction du menu de la marque.
* v0.3.0 (carte HUB-1, fusion #115) : le Hub, point central de la
* suite — nouvelle clé d'URL « hub » (jeton {{URL_HUB}} côté hôte,
* DRACO_BAR_CONFIG.urls.hub), défaut officiel
* https://hub.dracodev.net/, dérivation -dev hub-dev comme les
* sœurs. Le menu compte gagne trois vrais liens (patron
* buildAppsMenu : <a href>, classe draco-menu-item) — Télécharger
* → <hub>/telecharger, À Propos → <hub>/a-propos, Aides →
* <hub>/aide — et « Profil » devient lui aussi un lien, vers
* <hub>/profil (il n'est plus relayé aux hôtes : un seul Profil,
* celui du Hub). « Déconnexion » reste l'action relayée
* (data-compte-act="deconnexion" / onAction), inchangée.
* refreshMenuHrefs() resynchronise aussi les liens du Hub à
* l'ouverture des menus. Le Hub n'a pas d'onglet dans la barre :
* comme spark, sa clé est résolue et exposée via DRACO_BAR.urls,
* sans étape « href rendu » (voir « URL » ci-dessous).
* v0.4.0 (carte HUB-11, fusion #125) : icônes monoline dans le
* menu compte — chaque item (les 4 liens du Hub et « Déconnexion »)
* reçoit un glyphe SVG inline dans le style des icônes du rail de
* la console (contour, trait fin régulier, currentColor, aucun
* remplissage), aligné à gauche du libellé via .draco-menu-icone.
* L'en-tête d'identité ne reçoit pas d'icône. Aucun changement de
* comportement : liens résolus et Déconnexion relayée inchangés.
* v0.5.0 (carte HUB-18, fusion #132) : thème commun de la suite —
* la barre apprend la préférence de thème du profil du Hub
* (GET <hub>/api/profil/theme, cookie draco) et l'applique à
* l'ouverture par le mécanisme data-theme des hôtes, avec cache
* local court et repli intégral sur le comportement actuel de
* l'hôte si le Hub ne répond pas. Voir « Thème commun » plus bas.
* v0.6.0 (demande de Bruno, 2026-11-06) : le menu compte gagne un
* cinquième lien Hub, « Administration » → <hub>/admin (le Hub y
* redirige vers /admin/utilisateurs), placé après le séparateur,
* avant « Déconnexion », avec son icône monoline (curseurs de
* réglage). AFFICHAGE CONDITIONNEL : l'entrée n'apparaît que si
* l'hôte AFFIRME que le compte courant est administrateur de
* suite — cfg.admin === true à la configuration, ou
* DRACO_BAR.setAdmin(true) quand l'hôte l'apprend après coup
* (patron setUrls). L'hôte ne passe le drapeau que s'il l'a établi
* depuis SA source d'autorité (rôle global du compte chez
* ObsiGate, niveau du registre des rôles du Hub pour son app…) ;
* à défaut de certitude, pas de drapeau et l'entrée reste masquée.
* La barre ne devine jamais : le jeton draco ne porte aucun rôle
* (claims minimaux, par design). La vraie protection reste la
* garde serveur du Hub (/admin/*), pas ce masquage d'affichage.
* Voir le README, « Administration » — contrat de visibilité.
* v0.7.0 (chantier « entrée SSO de bout en bout », 2026-11-08) :
* l'API publique gagne DRACO_BAR.setUser(user) — l'hôte pousse le
* nom du compte courant DÈS qu'il l'établit, notamment une SPA qui
* termine son login sans rechargement (cas ObsiGate : le nom
* n'atteignait la barre qu'au chargement de la page suivante, le
* menu compte affichait l'en-tête générique « Compte » jusqu'au
* rechargement forcé). Même contrat de valeur que cfg.user
* (chaîne, ou objet display_name / name / username). L'avatar
* (initiale, infobulle) et l'en-tête du menu compte sont mis à
* jour immédiatement, sans rechargement ; null rétablit l'état
* neutre (✦, « Compte »).
*
* 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 +79,358 @@
* 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" }
* admin: true, // (v0.6.0, optionnel)
* // administrateur de suite ÉTABLI par l'hôte : démasque
* // l'entrée « Administration » du menu compte. Absent ou
* // non-strictement-true → entrée masquée. Peut aussi être
* // posé après coup via DRACO_BAR.setAdmin(true).
* urls: { // bases des applications
* obsigate: "https://og.dracodev.net/",
* flowdeck: "https://flowdeck.dracodev.net/",
* tiktak: "https://tiktak.dracodev.net/",
* kanbante: "https://kanbante.dracodev.net/",
* shaarit: "https://shaarit.dracodev.net/",
* agent: "https://agent.dracodev.net/",
* spark: "https://spark.dracodev.net/",
* hub: "https://hub.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, v0.2.0 : ObsiGate,
* Flowdeck, TikTak, Kanbante, ShaarIt, Console Spark) : elle suit
* donc l'étape « href
* rendu » comme les autres onglets, en plus d'alimenter le menu de
* la marque et DRACO_BAR.urls. Même résolution pour shaarit (v0.2.0) :
* explicite > dérivation -dev shaarit-dev > href rendu
* ({{URL_SHAARIT}}) > défaut officiel.
* Cas hub (v0.3.0) : PAS d'onglet, donc pas d'étape « href rendu » —
* explicite (DRACO_BAR_CONFIG.urls.hub, alimenté par le jeton
* {{URL_HUB}} remplacé côté serveur par l'hôte) > dérivation -dev
* hub-dev sur hôte -dev > défaut officiel
* https://hub.dracodev.net/. La base résolue sert les quatre liens
* du menu compte (telecharger, a-propos, aide, profil).
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, 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.
* Menu compte (v0.3.0) — Profil n'est PLUS relayé : c'est un lien
* vers <hub>/profil, comme Télécharger / À Propos / Aides (liens
* Hub, data-hub-link, href resynchronisés à chaque ouverture via
* refreshCompteHrefs / refreshMenuHrefs). Seule « Déconnexion »
* reste relayée à l'hôte (data-compte-act="deconnexion"), inchangée.
* Les hôtes qui traitaient onAction("profil") (Kanbante → /profile)
* voient ce comportement remplacé par le lien Hub — décision du
* plan Hub : un seul Profil, celui du Hub.
*/
(function () {
"use strict";
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/",
shaarit: "https://shaarit.dracodev.net/",
agent: "https://agent.dracodev.net/",
spark: "https://spark.dracodev.net/",
hub: "https://hub.dracodev.net/"
};
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
* 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/",
shaarit: "https://shaarit-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/",
hub: "https://hub-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante", shaarit: "ShaarIt"
};
/* 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();
/* ── Thème commun (v0.5.0, carte HUB-18, fusion #132) ────────────
* La préférence de thème de la suite vit dans le profil du Hub :
* GET <hub>/api/profil/theme → {"theme": "sombre"|"clair"|"systeme"|null}
* authentifié par le cookie draco (credentials: "include").
* <hub> est l'URL RÉSOLUE ci-dessus (explicite > dérivation -dev >
* défaut officiel) — jamais un nom figé de plus.
* Application par le mécanisme data-theme existant des hôtes :
* "sombre" → data-theme="dark", "clair" → data-theme="light",
* "systeme" → attribut RETIRÉ (prefers-color-scheme décide, garde
* html:not([data-theme]) du CSS de la barre comme des hôtes).
* L'événement draco:theme ({theme, mode} — mode = "dark"/"light"
* EFFECTIF) est ensuite dispatché pour les hôtes dont le mécanisme
* propre doit suivre (icônes et libellés, feuilles de style,
* palettes ShaarIt, attribut dédié de la console…) ; un hôte qui
* s'initialise après la barre lit la même information dans
* DRACO_BAR.themePref / DRACO_BAR.themeMode.
* Ordre d'application : 1) le cache local FRAIS est appliqué de
* façon SYNCHRONE à l'initialisation de la barre (script en defer
* chez tous les hôtes : avant DOMContentLoaded, donc avant le
* premier rendu dans le cas général) ; 2) un fetch ASYNCHRONE et
* NON BLOQUANT rafraîchit le cache et ré-applique si la préférence
* a changé. À la toute première visite (aucun cache), un bref
* flash du thème local de l'hôte reste possible le temps du fetch :
* c'est le prix du repli — l'état affiché transitoire est le thème
* que l'hôte aurait de toute façon choisi seul, jamais un état
* cassé, et les visites suivantes sont servies par le cache.
* Cache : localStorage "draco-theme-hub" ({theme, ts}), TTL 12 h
* (ordre de la journée) ; il ne contient QUE la préférence du Hub,
* jamais le choix local de l'hôte.
* REPLI INTÉGRAL : Hub injoignable, réponse non-OK (401…), JSON
* invalide, theme null ou valeur inconnue → RIEN n'est appliqué et
* RIEN n'est mis en cache ; un échec de fetch n'efface pas un cache
* existant. Le comportement actuel de l'hôte (son localStorage,
* puis le système) reste alors exactement le maître, comme avant
* la v0.5.0. Le sélecteur de thème LOCAL de chaque hôte reste
* fonctionnel : la barre n'applique qu'à l'ouverture (cache, puis
* fetch) ; elle ne ré-applique ensuite que sur changement du
* schéma système, et seulement tant que la préférence résolue est
* "systeme". */
var THEME_CACHE_CLE = "draco-theme-hub";
var THEME_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
var themePref = null; /* "sombre"|"clair"|"systeme"|null (non résolue) */
var themeMode = null; /* "dark"|"light"|null : mode effectif appliqué */
function themeSystemeEstSombre() {
try {
return !!(window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
} catch (e) { return false; }
}
function lireCacheTheme() {
try {
var brut = window.localStorage &&
window.localStorage.getItem(THEME_CACHE_CLE);
if (!brut) return null;
var c = JSON.parse(brut);
if (!c || typeof c.ts !== "number") return null;
if (Date.now() - c.ts > THEME_CACHE_TTL_MS) return null;
if (c.theme === "sombre" || c.theme === "clair" || c.theme === "systeme")
return c.theme;
} catch (e) { /* stockage indisponible ou corrompu : pas de cache */ }
return null;
}
function ecrireCacheTheme(theme) {
try {
if (window.localStorage) {
window.localStorage.setItem(THEME_CACHE_CLE,
JSON.stringify({ theme: theme, ts: Date.now() }));
}
} catch (e) { /* stockage indisponible : le prochain fetch refera le travail */ }
}
function appliquerThemeCommun(theme) {
var root = document.documentElement;
if (!root) return;
if (theme === "sombre" || theme === "clair") {
themeMode = theme === "sombre" ? "dark" : "light";
root.setAttribute("data-theme", themeMode);
} else if (theme === "systeme") {
themeMode = themeSystemeEstSombre() ? "dark" : "light";
root.removeAttribute("data-theme");
} else {
return;
}
themePref = theme;
if (window.DRACO_BAR) {
window.DRACO_BAR.themePref = themePref;
window.DRACO_BAR.themeMode = themeMode;
}
try {
document.dispatchEvent(new CustomEvent("draco:theme",
{ detail: { theme: themePref, mode: themeMode } }));
} catch (e) { /* aucun écouteur hôte : data-theme suffit */ }
}
function initThemeCommun() {
var enCache = lireCacheTheme();
if (enCache) appliquerThemeCommun(enCache);
/* Suivi du schéma système tant que la préférence est "systeme" :
* sans data-theme, le CSS suit tout seul ; l'événement est
* re-dispatché pour les hôtes qui matérialisent le mode. */
try {
var mq = window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)");
if (mq && mq.addEventListener) {
mq.addEventListener("change", function () {
if (themePref === "systeme") appliquerThemeCommun("systeme");
});
}
} catch (e) { /* pas de matchMedia : pas de suivi */ }
if (typeof window.fetch !== "function") return;
var url = withSlash(resolvedUrls.hub || DEFAULT_URLS.hub) +
"api/profil/theme";
window.fetch(url, { credentials: "include" }).then(function (resp) {
if (!resp || !resp.ok) return null;
return resp.json();
}).then(function (data) {
if (!data) return;
var t = data.theme;
if (t === "sombre" || t === "clair" || t === "systeme") {
ecrireCacheTheme(t);
appliquerThemeCommun(t);
}
/* theme null ou inconnu : repli intégral, rien n'est touché. */
}).catch(function () { /* Hub injoignable : repli intégral */ });
}
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
@@ -43,26 +451,132 @@
});
}
/* ── 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]);
});
/* v0.3.0 : les liens du Hub (menu compte) sont resynchronisés
* eux aussi à l'ouverture d'un menu, pour refléter setUrls et
* la dérivation -dev même s'ils ont été construits avant. */
refreshCompteHrefs();
}
/* Liens du Hub dans le menu compte (v0.3.0) : chemin → libellé.
* « Profil » est le quatrième lien (vers <hub>/profil) ; il
* remplace l'ancien item relayé « profil ». */
var HUB_LINKS = [
["telecharger", "Télécharger"],
["a-propos", "À Propos"],
["aide", "Aides"],
["profil", "Profil"]
];
function hubHref(path) {
return withSlash(resolvedUrls.hub || DEFAULT_URLS.hub) + path;
}
function refreshCompteHrefs() {
if (!compteMenu) return;
compteMenu.querySelectorAll("[data-hub-link]").forEach(function (a) {
a.setAttribute("href", hubHref(a.getAttribute("data-hub-link")));
});
}
function togglePopup(menu, btn, force) {
if (!menu) return;
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 +598,348 @@
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 les onglets d'apps
* (ObsiGate…ShaarIt, v0.2.0), vers la console
* de l'agent commun (URL agent résolue — explicite > -dev >
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
* autres items ; 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");
/* Icônes monoline du menu compte (v0.4.0) — glyphes contour
* dans le style des icônes du rail de la console : viewBox 24,
* stroke currentColor, remplissage nul, extrémités rondes.
* Chaque glyphe est une liste de primitives [balise, attributs]
* construites en DOM (jamais d'innerHTML), comme le reste du
* fichier. Le libellé reste le nœud texte de l'item ; l'icône,
* appendue après, est replacée à gauche par le CSS (order: -1
* sur .draco-menu-icone, l'item étant déjà en flex). */
var MENU_ICONES = {
/* Flèche vers le bas dans un plateau. */
"telecharger": [
["path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }],
["polyline", { points: "7 10 12 15 17 10" }],
["line", { x1: "12", y1: "15", x2: "12", y2: "3" }]
],
/* Cercle d'information. */
"a-propos": [
["circle", { cx: "12", cy: "12", r: "9" }],
["line", { x1: "12", y1: "11", x2: "12", y2: "16" }],
["line", { x1: "12", y1: "8", x2: "12.01", y2: "8" }]
],
/* Cercle d'interrogation. */
"aide": [
["circle", { cx: "12", cy: "12", r: "9" }],
["path", { d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }],
["line", { x1: "12", y1: "17", x2: "12.01", y2: "17" }]
],
/* Personne : tête + épaules. */
"profil": [
["path", { d: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" }],
["circle", { cx: "12", cy: "7", r: "4" }]
],
/* Curseurs de réglage (trois axes, trois boutons) — v0.6.0. */
"admin": [
["line", { x1: "4", y1: "21", x2: "4", y2: "14" }],
["line", { x1: "4", y1: "10", x2: "4", y2: "3" }],
["line", { x1: "12", y1: "21", x2: "12", y2: "12" }],
["line", { x1: "12", y1: "8", x2: "12", y2: "3" }],
["line", { x1: "20", y1: "21", x2: "20", y2: "16" }],
["line", { x1: "20", y1: "12", x2: "20", y2: "3" }],
["line", { x1: "1", y1: "14", x2: "7", y2: "14" }],
["line", { x1: "9", y1: "8", x2: "15", y2: "8" }],
["line", { x1: "17", y1: "16", x2: "23", y2: "16" }]
],
/* Flèche sortant d'un cadre (porte). */
"deconnexion": [
["path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }],
["polyline", { points: "16 17 21 12 16 7" }],
["line", { x1: "21", y1: "12", x2: "9", y2: "12" }]
]
};
/* Espace de noms SVG — OBLIGATOIRE : un élément « svg » (ou
* « path », « circle »…) créé par createElement() appartient à
* l'espace de noms HTML et le navigateur ne le dessine JAMAIS
* (bug v0.4.0 : libellés décalés d'un cran d'icône, aucun glyphe
* visible ; corrigé en v0.4.1, carte fusion #125). */
var SVG_NS = "http://www.w3.org/2000/svg";
function menuIcone(nom) {
var span = document.createElement("span");
span.className = "draco-menu-icone";
span.setAttribute("aria-hidden", "true");
var svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.8");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("focusable", "false");
(MENU_ICONES[nom] || []).forEach(function (spec) {
var el = document.createElementNS(SVG_NS, spec[0]);
for (var k in spec[1]) el.setAttribute(k, spec[1][k]);
svg.appendChild(el);
});
span.appendChild(svg);
return span;
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (el && el.dataset.act === "apps") {
ev.preventDefault();
toggleMenu();
/* Visibilité de l'entrée « Administration » (v0.6.0) : vraie
* seulement si l'hôte l'a AFFIRMÉE — cfg.admin === true à la
* configuration, ou DRACO_BAR.setAdmin(true) après coup. La
* barre ne déduit jamais ce statut (le jeton draco ne porte
* aucun rôle) ; à défaut, l'entrée reste masquée et la garde
* serveur du Hub reste la vraie protection. */
var compteAdmin = cfg.admin === true;
var adminItem = null;
/* Menu compte (v0.1.6 ; v0.3.0 pour les liens) — en-tête
* d'identité, puis les liens du Hub (Télécharger, À Propos,
* Aides, Profil — vrais <a href>, même patron que le menu des
* applications), un séparateur, puis « Déconnexion » relayée à
* l'hôte. L'en-tête et les href sont rafraîchis à chaque
* ouverture, pour refléter un title posé après coup par l'hôte
* (ex. ObsiGate, qui remplit l'avatar depuis /api/auth/me après
* chargement) et la résolution d'URL la plus récente. */
function buildCompteMenu() {
if (!bar || !avatarBtn || compteMenu) return;
compteMenu = document.createElement("div");
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);
HUB_LINKS.forEach(function (pair) {
var a = document.createElement("a");
a.className = "draco-menu-item";
a.href = hubHref(pair[0]);
a.setAttribute("data-hub-link", pair[0]);
a.setAttribute("role", "menuitem");
a.textContent = pair[1];
a.appendChild(menuIcone(pair[0]));
compteMenu.appendChild(a);
});
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
compteMenu.appendChild(sep);
/* « Administration » (v0.6.0) : cinquième lien Hub
* (<hub>/admin, où le Hub redirige vers /admin/utilisateurs).
* data-hub-link="admin" : son href est resynchronisé par
* refreshCompteHrefs / setUrls comme les quatre autres liens.
* Toujours construit ; masqué tant que l'hôte n'a pas affirmé
* le statut d'administrateur (compteAdmin). */
var adm = document.createElement("a");
adm.className = "draco-menu-item";
adm.href = hubHref("admin");
adm.setAttribute("data-hub-link", "admin");
adm.setAttribute("role", "menuitem");
adm.textContent = "Administration";
adm.appendChild(menuIcone("admin"));
adm.hidden = !compteAdmin;
compteMenu.appendChild(adm);
adminItem = adm;
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", "deconnexion");
b.setAttribute("role", "menuitem");
b.textContent = "Déconnexion";
b.appendChild(menuIcone("deconnexion"));
compteMenu.appendChild(b);
bar.appendChild(compteMenu);
}
function refreshCompteMenu() {
if (!compteMenu) return;
var head = compteMenu.querySelector(".draco-compte-nom");
var name = displayName();
if (head) head.textContent = name || "Compte";
if (adminItem) adminItem.hidden = !compteAdmin;
refreshCompteHrefs();
}
function toggleCompteMenu(force) {
buildCompteMenu();
if (!compteMenu) return;
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
togglePopup(compteMenu, avatarBtn, force);
if (!compteMenu.hidden) lastOpener = avatarBtn;
}
/* Relais d'une action vers l'hôte : onAction si présent, sinon
* événement draco:bar (contrat historique des actions relayées). */
function relay(act, el, ev) {
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
}
/* Écouteurs globaux posés sur le prototype EventTarget quand il
* existe, pour survivre au registre/teardown d'un hôte SPA (TikTak,
* voir l'en-tête) ; sinon document.addEventListener classique. */
function addGlobalListener(type, fn) {
try {
if (typeof EventTarget !== "undefined" && EventTarget.prototype &&
EventTarget.prototype.addEventListener) {
EventTarget.prototype.addEventListener.call(document, type, fn, false);
return;
}
} catch (e) { /* repli ci-dessous */ }
document.addEventListener(type, fn);
}
addGlobalListener("click", function (ev) {
/* Items du menu compte : relais + fermeture, jamais de navigation. */
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
if (compteItem) {
relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
toggleCompteMenu(false);
return;
}
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
toggleMenu(false);
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,
/* Thème commun (v0.5.0) : préférence du Hub résolue et mode
* effectif appliqué ; null tant que rien n'a été résolu (repli). */
themePref: null, themeMode: null,
setUrls: function (urls) {
cfg.urls = Object.assign({}, cfg.urls, urls || {});
resolvedUrls = resolveUrls();
window.DRACO_BAR.urls = resolvedUrls;
applyUrls(resolvedUrls);
refreshCompteHrefs();
},
/* Administration (v0.6.0) : l'hôte pose le drapeau quand il a
* ÉTABLI que le compte courant est administrateur de suite
* (sa source d'autorité — voir l'en-tête et le README). Toute
* valeur autre que le booléen true masque l'entrée. */
setAdmin: function (v) {
compteAdmin = v === true;
if (adminItem) adminItem.hidden = !compteAdmin;
},
/* Identité (v0.7.0) : l'hôte pousse le nom du compte courant
* dès qu'il l'établit — même contrat de valeur que cfg.user
* (chaîne, ou objet display_name / name / username). L'avatar
* et l'en-tête du menu compte sont resynchronisés aussitôt,
* sans rechargement. setUser(null) rétablit l'état neutre. */
setUser: function (u) {
cfg.user = u || null;
if (!u) {
var av = document.querySelector(".draco-avatar");
if (av) {
av.textContent = "✦";
av.removeAttribute("title");
av.setAttribute("aria-label", "Compte");
}
}
syncAvatar();
refreshCompteMenu();
}
};
buildMenu();
applyUrls(resolvedUrls);
buildAppsMenu();
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
initThemeCommun();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
+7 -1
View File
@@ -23,6 +23,12 @@
kanbante: "#0d9488",
};
/* Base TikTak fournie par l'hôte via window.DRACO_URLS (contrat
* d'injection dracodev-commons, #223) ; repli = défaut officiel. */
function baseTikTak() {
return (window.DRACO_URLS && window.DRACO_URLS.tiktak) || "https://tiktak.dracodev.net";
}
function t(key, fallback) {
try {
var v = window.t && window.t(key);
@@ -92,7 +98,7 @@
t("draco.links.projected", "Tâche TikTak projetée")));
var ouvrir = el("a", "btn-action draco-liens-ouvrir",
t("draco.links.openInTikTak", "Ouvrir dans TikTak"));
ouvrir.href = "https://tiktak.dracodev.net/?task=" + tid[1];
ouvrir.href = baseTikTak() + "/?task=" + tid[1];
ouvrir.target = "_blank";
ouvrir.rel = "noopener";
bandeau.appendChild(ouvrir);
+89 -17
View File
@@ -37,16 +37,19 @@
display: flex; align-items: center; gap: 9px;
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
}
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
.draco-panneau-titres { min-width: 0; flex: 1; }
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.draco-panneau-hd .soustitre {
font-size: 12px; color: var(--p-doux); display: flex; align-items: center; gap: 6px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.draco-panneau-hd .soustitre .point { width: 8px; height: 8px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-hd .soustitre.ok .point { background: #2ea36b; }
.draco-panneau-hd .soustitre.err .point { background: #cf4b32; }
.draco-panneau-hd .fermer {
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px; flex: none;
}
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-etat.ok .point { background: #2ea36b; }
.draco-panneau-etat.err .point { background: #cf4b32; }
/* Barre des discussions (façon Muse) */
.draco-disc-bar {
display: flex; align-items: center; gap: 7px;
@@ -112,18 +115,87 @@
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
.draco-panneau-form { display: flex; gap: 8px; }
.draco-panneau-form input {
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
font: inherit; background: var(--p-champ); color: var(--p-texte);
.draco-panneau-ft { padding: 10px 12px 11px; background: var(--p-fond); }
/* Puces de suggestions, comme la console : au-dessus du composer. */
.draco-puces {
display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; padding: 0 2px 8px;
}
.draco-panneau-form button {
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
.draco-puces button {
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
}
.draco-puces button:hover { background: var(--p-chip-survol); }
/* ── Composer façon console (pilule) ─────────────────────────
* +, textarea auto-extensible, micro ou envoi rond bleu ↑ ;
* pendant la dictée, la rangée d'enregistrement remplace la saisie. */
.draco-composer {
display: flex; flex-direction: column; align-items: stretch; gap: 6px;
background: var(--p-surface); border: 1px solid var(--p-bord);
border-radius: 24px; padding: 8px 10px;
box-shadow: 0 1px 4px rgb(0 0 0 / .05);
}
.draco-composer [hidden] { display: none !important; }
.draco-composer-ligne { display: flex; align-items: flex-end; gap: 6px; }
.draco-composer textarea {
flex: 1; border: 0; background: none; resize: none; outline: none;
padding: 8px 2px; max-height: 152px; overflow-y: auto; line-height: 1.45;
min-height: calc(1.45em + 16px); font: inherit; color: var(--p-texte);
scrollbar-width: none;
}
.draco-composer textarea::-webkit-scrollbar { width: 0; height: 0; display: none; }
.draco-composer .composer-btn {
width: 36px; height: 36px; border-radius: 50%; border: 0; flex: none;
background: var(--p-chip); color: var(--p-texte); font-size: 17px;
display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
}
.draco-composer .composer-btn:hover { background: var(--p-chip-survol); }
.draco-composer .composer-btn.danger { color: var(--p-danger); }
.draco-composer .composer-envoi {
width: 36px; height: 36px; border-radius: 50%; border: 0; flex: none;
background: #2f6fed; color: #fff; font-size: 17px; font-weight: 700;
display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
}
.draco-composer .composer-envoi:disabled { opacity: .5; }
.draco-composer .composer-btn .ic, .draco-composer .composer-envoi .ic { width: 19px; height: 19px; }
.draco-composer-pieces { display: flex; flex-wrap: wrap; gap: 8px; padding: 2px 2px 0; }
.draco-composer-pieces .piece-carte {
position: relative; display: flex; align-items: center; gap: 8px;
background: var(--p-chip); border: 1px solid var(--p-chip-bord);
border-radius: 12px; padding: 5px 9px 5px 5px; max-width: 230px;
}
.draco-composer-pieces .piece-carte img { width: 42px; height: 42px; object-fit: cover; border-radius: 8px; display: block; }
.draco-composer-pieces .piece-nom { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.draco-composer-pieces .piece-doc-ext {
width: 42px; height: 42px; display: flex; align-items: center; justify-content: center;
background: var(--p-chip); border: 1px solid var(--p-chip-bord); border-radius: 8px;
}
.draco-composer-pieces .piece-ext { font-weight: 700; font-size: 10px; letter-spacing: .04em; text-transform: uppercase; opacity: .75; }
.draco-composer-pieces .piece-retirer {
position: absolute; top: -7px; right: -7px; width: 20px; height: 20px;
border-radius: 50%; border: 0; background: var(--p-texte); color: var(--p-fond);
font-size: 10px; line-height: 1; padding: 0; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.draco-composer-enreg { display: flex; align-items: center; gap: 10px; padding: 0 2px; }
.draco-composer-enreg .enreg-temps { font-variant-numeric: tabular-nums; font-weight: 700; min-width: 36px; }
.draco-composer-enreg .enreg-texte {
flex: 1; min-width: 0; color: var(--p-doux); font-size: 13px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.draco-composer-enreg .enreg-onde { display: flex; align-items: center; gap: 3px; height: 26px; flex: none; }
.draco-composer-enreg .enreg-onde span { width: 4px; height: 30%; border-radius: 2px; background: #2f6fed; }
.draco-composer.en-cours .enreg-onde span { animation: onde 1.05s ease-in-out infinite; }
.draco-composer-enreg .enreg-onde span:nth-child(3n+2) { animation-delay: .14s; }
.draco-composer-enreg .enreg-onde span:nth-child(3n) { animation-delay: .28s; }
@keyframes onde { 0%, 100% { height: 22%; } 50% { height: 96%; } }
/* Pièces jointes dans une bulle utilisateur */
.draco-msg-pieces { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 7px; }
.draco-msg-pieces img { max-width: 150px; max-height: 120px; border-radius: 10px; display: block; }
.draco-msg-pieces .piece-fichier {
display: inline-flex; align-items: center; gap: 7px;
background: var(--p-chip); border: 1px solid var(--p-chip-bord); color: var(--p-texte);
border-radius: 9px; padding: 6px 9px; font-size: 12px;
}
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
+494 -33
View File
@@ -18,21 +18,34 @@
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
* (persistée), thème clair/sombre aligné sur l'app hôte
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
* Bouton « Console ↗ » (barre des discussions) : ouvre la console web
* du même service (agent.dracodev.net) dans un nouvel onglet, sur la
* discussion courante (?disc=) et avec l'app d'origine (?app=),
* déduite du sous-domaine de l'hôte.
* Bouton « Console » (barre des discussions) : ouvre la console web
* du même service (agent.dracodev.net) DANS la page, en surimpression
* pleine page (iframe) qui naît à la géométrie du panneau puis
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
* courante, ?app= app d'origine déduite du sous-domaine) est celle
* du premier chargement — l'iframe est créé paresseusement et
* conservé, la console garde son état entre ouvertures. La barre
* d'outils de la surimpression offre « Mode sidebar » (retour au
* panneau seul, animation inverse) et « Fermer l'agent » (referme
* surimpression et panneau).
* États honnêtes : service injoignable / session non reconnue / LLM non
* configuré / exécuteur non configuré sont affichés tels quels. */
(function () {
"use strict";
var API = "https://agent.dracodev.net";
/* URL du service : fournie par l'hôte via window.DRACO_URLS
* (contrat d'injection dracodev-commons, #223) — le repli est le
* défaut officiel ; aucun nom d'environnement n'est figé ici. */
var API = (window.DRACO_URLS && window.DRACO_URLS.agent) || "https://agent.dracodev.net";
var convId = null;
var convKind = null; // 'principale' | 'parallele'
var convTitre = "";
var etatConnu = false;
var domaineCharge = null; // domaine des discussions affichées
var consoleUrlCharge = null; // URL (disc+domaine+app) de l'iframe console
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var microEl = null, piecesEl = null, fichierEl = null;
var titreEl = null, tiroirEl = null;
var versionEl = null, versionConnue = "", versionMaj = 0;
function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
@@ -48,23 +61,144 @@
catch (e) { return null; }
}
/* ── Console web (même service, nouvel onglet) ─────────────── */
/* ── Console web (même service, surimpression pleine page) ─── */
function appHote() {
var h = (window.location.hostname || "").split(".")[0];
/* Premier label de l'hôte, suffixe d'environnement éventuel
* retiré (ex. « og-dev » → « og » en dev, #223). */
var h = (window.location.hostname || "").split(".")[0].replace(/-dev$/, "");
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
kanbante: "kanbante", og: "obsigate" };
return correspondance[h] || null;
}
function ouvrirConsole() {
function urlConsole() {
/* Le domaine de l'hôte est passé explicitement : la console
* vit sur une autre origine, son localStorage ne voit jamais
* celui de l'app hôte — sans ce paramètre, elle rouvrait
* toujours le domaine de sa propre origine, pas celui du site. */
var params = [];
var id = convId || convStockee();
if (id) params.push("disc=" + encodeURIComponent(id));
var app = appHote();
if (app) params.push("app=" + encodeURIComponent(app));
window.open(API + "/" + (params.length ? "?" + params.join("&") : ""),
"_blank", "noopener");
params.push("domain=" + encodeURIComponent(domaine()));
return API + "/" + (params.length ? "?" + params.join("&") : "");
}
/* La surimpression vit entièrement dans ce fichier (DOM + styles
* injectés) : les apps hôtes n'ont que ce fichier à recopier. Son
* thème suit celui du panneau (data-draco-theme synchronisé par
* appliquerTheme) et sa palette reprend les mêmes valeurs que
* draco-panneau.css. L'iframe est créé au premier clic puis
* conservé : rouvrir la console ne le recharge pas. */
var surEl = null, surCadre = null, surOuverte = false;
var STYLES_CONSOLE =
".draco-console-sur{" +
"--p-fond:#fbfbfa;--p-surface:#ffffff;--p-bord:#e4e5e0;--p-texte:#242624;" +
"--p-chip:#f4f5f1;--p-chip-bord:#d6d8d1;--p-chip-survol:#e9ebe4;--p-accent:#7c5cd6;" +
"position:fixed;top:0;right:0;height:100dvh;width:388px;max-width:100vw;z-index:1000;" +
"background:var(--p-fond);color:var(--p-texte);" +
"box-shadow:-12px 0 36px rgb(0 0 0 / .22);" +
"display:flex;flex-direction:column;transition:width .3s ease;" +
"font:14px/1.5 system-ui,\"Segoe UI\",sans-serif}" +
".draco-console-sur[data-draco-theme=\"dark\"]{" +
"--p-fond:#17181a;--p-surface:#1f2023;--p-bord:#33353a;--p-texte:#eceeeb;" +
"--p-chip:#26272b;--p-chip-bord:#3a3c41;--p-chip-survol:#2e3036}" +
".draco-console-sur[hidden]{display:none}" +
".draco-console-barre{display:flex;align-items:center;gap:9px;flex:none;" +
"padding:9px 12px;border-bottom:1px solid var(--p-bord);background:var(--p-surface)}" +
".draco-console-barre .dc-titre{font-weight:700;font-size:14px;" +
"display:flex;align-items:center;gap:8px;white-space:nowrap}" +
".draco-console-barre .dc-etoile{color:var(--p-accent);font-size:17px}" +
".draco-console-barre .dc-actions{margin-left:auto;display:flex;gap:7px}" +
".draco-console-barre button{border:1px solid var(--p-chip-bord);" +
"background:var(--p-chip);color:var(--p-texte);border-radius:999px;" +
"padding:5px 12px;font:600 12.5px system-ui;cursor:pointer;white-space:nowrap}" +
".draco-console-barre button:hover{background:var(--p-chip-survol)}" +
".draco-console-cadre{flex:1;width:100%;border:0;display:block;background:var(--p-fond)}";
function assurerSurimpression() {
if (surEl) return;
if (!document.getElementById("draco-console-styles")) {
var st = document.createElement("style");
st.id = "draco-console-styles";
st.textContent = STYLES_CONSOLE;
document.head.appendChild(st);
}
surEl = document.createElement("div");
surEl.className = "draco-console-sur";
surEl.setAttribute("aria-label", "Console Spark");
surEl.hidden = true;
surEl.innerHTML =
'<div class="draco-console-barre">' +
' <span class="dc-titre"><span class="dc-etoile">✦</span> Console Spark</span>' +
' <span class="dc-actions">' +
' <button type="button" data-dc="sidebar" title="Réduire la console et revenir au panneau latéral">◧ Mode sidebar</button>' +
' <button type="button" data-dc="fermer" title="Fermer la console et le panneau Spark">✕ Fermer l\'agent</button>' +
' </span></div>' +
'<iframe class="draco-console-cadre" title="Console Spark" allow="microphone"></iframe>';
document.body.appendChild(surEl);
surCadre = surEl.querySelector("iframe");
surEl.querySelector('[data-dc="sidebar"]')
.addEventListener("click", modeSidebarConsole);
surEl.querySelector('[data-dc="fermer"]')
.addEventListener("click", fermerAgentConsole);
appliquerTheme();
}
function largeurPanneau() {
if (root) {
var w = root.getBoundingClientRect().width;
if (w > 0) return Math.round(w);
}
return 388;
}
function ouvrirConsole() {
assurerSurimpression();
/* L'iframe n'est rechargée que si sa cible a changé
* (discussion, domaine ou app) : même cible = état conservé,
* cible différente = la console s'ajuste, au lieu de garder
* la discussion du premier chargement. */
var url = urlConsole();
if (!surCadre.getAttribute("src") || url !== consoleUrlCharge) {
surCadre.src = url;
consoleUrlCharge = url;
}
if (surOuverte) return;
surOuverte = true;
/* Naissance à la géométrie du panneau (ancrée à droite), puis
* extension à toute la page ; le double rAF laisse le temps de
* peindre la largeur de départ, sinon la transition ne joue pas. */
surEl.style.width = largeurPanneau() + "px";
surEl.hidden = false;
requestAnimationFrame(function () {
requestAnimationFrame(function () { surEl.style.width = "100%"; });
});
}
function retrecirConsole() {
if (!surEl || !surOuverte) return;
surOuverte = false;
var fini = false;
function terminer() {
if (fini) return;
fini = true;
surEl.removeEventListener("transitionend", surFin);
surEl.hidden = true; // l'iframe reste chargé, juste masqué
}
function surFin(ev) { if (ev.propertyName === "width") terminer(); }
surEl.addEventListener("transitionend", surFin);
setTimeout(terminer, 380); // filet si transitionend ne vient pas
surEl.style.width = largeurPanneau() + "px";
}
function modeSidebarConsole() {
retrecirConsole();
/* Retour au panneau : si le domaine a changé pendant que la
* console occupait tout l'écran, le panneau s'ajuste aussi. */
resynchroniserDomaine();
}
function fermerAgentConsole() { fermer(); retrecirConsole(); }
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
function themeHote() {
var t = document.documentElement.getAttribute("data-theme");
@@ -78,6 +212,7 @@
}
function appliquerTheme() {
if (root) root.setAttribute("data-draco-theme", themeHote());
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
}
function surveillerTheme() {
try {
@@ -132,18 +267,15 @@
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
'<div class="draco-panneau-hd">' +
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
' <div><div class="titre">Spark — agent commun</div>' +
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
' <div class="draco-panneau-titres"><div class="titre">Spark — agent commun</div>' +
' <div class="soustitre"><span class="point"></span><span class="txt">Connexion au service…</span></div></div>' +
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
'</div>' +
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
'</div>' +
'<div class="draco-disc-bar">' +
' <span class="d-titre">Discussion principale</span>' +
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (agent.dracodev.net) dans un nouvel onglet, sur cette discussion">Console ↗</button>' +
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (agent.dracodev.net) en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
'</div>' +
'<div class="draco-discussions" hidden></div>' +
'<div class="draco-panneau-outils">' +
@@ -152,18 +284,43 @@
' <button type="button" data-outil="missions">🚀 Missions</button>' +
'</div>' +
'<div class="draco-panneau-msgs"></div>' +
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
'<div class="draco-panneau-ft">' +
' <div class="draco-puces">' +
' <button type="button" data-sugg="resume">Résumer ma journée</button>' +
' <button type="button" data-sugg="projets">Où en sont mes projets ?</button>' +
' <button type="button" data-sugg="chercher">Chercher partout…</button>' +
' </div>' +
' <form class="draco-panneau-form draco-composer">' +
' <div class="draco-composer-pieces" hidden></div>' +
' <div class="draco-composer-ligne">' +
' <button type="button" class="composer-btn" data-comp="plus" title="Joindre une image ou un document" aria-label="Joindre une image ou un document"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>' +
' <textarea rows="1" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark"></textarea>' +
' <input type="file" accept="image/*,.pdf,.docx,.odt,.txt,.md,.html,.csv" multiple hidden>' +
' <button type="button" class="composer-btn" data-comp="micro" title="Dictée vocale" aria-label="Dictée vocale" hidden><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><path d="M12 19v3"/></svg></button>' +
' <button type="submit" class="composer-envoi" title="Envoyer" aria-label="Envoyer" hidden><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20V4.5"/><path d="m5.5 11 6.5-6.5 6.5 6.5"/></svg></button>' +
' </div>' +
' <div class="draco-composer-enreg" hidden>' +
' <button type="button" class="composer-btn danger" data-comp="enreg-annuler" title="Annuler la dictée" aria-label="Annuler la dictée"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>' +
' <span class="enreg-temps">0:00</span>' +
' <div class="enreg-onde" aria-hidden="true"></div>' +
' <span class="enreg-texte"></span>' +
' <button type="button" class="composer-btn" data-comp="enreg-stop" title="Arrêter et corriger la transcription" aria-label="Arrêter la dictée"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/></svg></button>' +
' <button type="button" class="composer-envoi" data-comp="enreg-envoyer" title="Arrêter et envoyer" aria-label="Arrêter et envoyer"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20V4.5"/><path d="m5.5 11 6.5-6.5 6.5 6.5"/></svg></button>' +
' </div>' +
' </form>' +
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
etatEl = root.querySelector(".draco-panneau-etat");
inputEl = root.querySelector("input");
sendBtn = root.querySelector("button[type=submit]");
etatEl = root.querySelector(".draco-panneau-hd .soustitre");
inputEl = root.querySelector(".draco-composer textarea");
sendBtn = root.querySelector(".draco-composer button[type=submit]");
microEl = root.querySelector('[data-comp="micro"]');
piecesEl = root.querySelector(".draco-composer-pieces");
fichierEl = root.querySelector(".draco-composer input[type=file]");
titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions");
versionEl = root.querySelector(".draco-panneau-version");
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
@@ -172,13 +329,55 @@
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
root.querySelector("form").addEventListener("submit", envoyer);
inputEl.addEventListener("input", majComposer);
inputEl.addEventListener("keydown", function (ev) {
if (ev.key === "Enter" && !ev.shiftKey) { ev.preventDefault(); envoyerComposer(); }
});
root.querySelector('[data-comp="plus"]').addEventListener("click", function () { fichierEl.click(); });
fichierEl.addEventListener("change", function (ev) {
ajouterFichiers(ev.target.files);
ev.target.value = "";
});
microEl.addEventListener("click", demarrerDictee);
root.querySelector('[data-comp="enreg-stop"]').addEventListener("click", function () { arreterDictee("stop"); });
root.querySelector('[data-comp="enreg-annuler"]').addEventListener("click", function () { arreterDictee("annuler"); });
root.querySelector('[data-comp="enreg-envoyer"]').addEventListener("click", function () { arreterDictee("envoyer"); });
Array.prototype.forEach.call(root.querySelectorAll("[data-sugg]"), function (b) {
b.addEventListener("click", function () {
var mode = b.getAttribute("data-sugg");
if (mode === "resume") {
envoyerMessage("Résume ma journée : mes tâches du jour, les cartes en Révision et l'activité récente.", []);
} else if (mode === "projets") {
envoyerMessage("Où en sont mes projets ? Fais le point sur l'état des projets et du board.", []);
} else {
inputEl.value = "Cherche « » partout : ";
majComposer();
inputEl.focus();
inputEl.setSelectionRange(9, 9);
}
});
});
/* Domaine changé dans un autre onglet pendant que le panneau
* existe : l'événement storage ne part que vers les autres
* documents, donc ici il arrive bien. Le changement dans le
* même onglet est rattrapé à chaque ouverture (ouvrir()). */
window.addEventListener("storage", function (ev) {
if (ev.key === "draco_domain") resynchroniserDomaine();
});
majComposer();
brancherPoignee(root.querySelector(".draco-poignee"));
appliquerTheme();
surveillerTheme();
appliquerLargeur();
window.addEventListener("resize", appliquerLargeur);
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
if (ev.key !== "Escape") return;
/* Surimpression ouverte : Échap = retour au panneau seul
* (« Mode sidebar »). Quand le focus est dans l'iframe de la
* console (autre origine), l'événement ne remonte pas
* jusqu'ici : il ne peut donc pas fermer par surprise. */
if (surOuverte) { modeSidebarConsole(); return; }
if (root.classList.contains("open")) fermer();
});
}
@@ -204,14 +403,48 @@
}
function majEtat(cls, texte) {
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
etatEl.querySelector(".txt").textContent = texte;
/* Statut intégré au titre (sous-titre de l'en-tête) : plus de
* barre d'état séparée. Le point vert/rouge/gris reste lisible
* sans prendre une rangée entière. */
if (!etatEl) return;
etatEl.className = "soustitre " + cls;
var t = etatEl.querySelector(".txt");
if (t) t.textContent = texte;
}
function majTitre() {
titreEl.textContent = convTitre || "Discussion principale";
titreEl.title = titreEl.textContent;
}
/* ── Version du service : lue en direct dans la santé de
* l'agent (GET /healthz → champ « version », la même source que
* la console) à l'origine que le panneau connaît déjà. Cache de
* session (5 min), rafraîchie à l'ouverture du panneau, au même
* rythme que la vérification d'identité. Jamais de valeur codée
* en dur : source injoignable ou réponse illisible = la ligne
* reste masquée — pas de fausse version, pas d'erreur visible. */
function afficherVersion() {
if (!versionEl) return;
if (versionConnue) {
versionEl.textContent = "Service d'agents v" + versionConnue;
versionEl.hidden = false;
}
}
function chargerVersion() {
if (!API || !versionEl) return;
if (versionConnue && Date.now() - versionMaj < 300000) { afficherVersion(); return; }
fetch(API + "/healthz")
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (d && typeof d.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+/.test(d.version)) {
versionConnue = d.version;
versionMaj = Date.now();
afficherVersion();
}
})
.catch(function () { /* service injoignable : rien d'affiché */ });
}
function verifierIdentite() {
if (etatConnu) return;
fetch(API + "/api/v1/me", { credentials: "include" })
@@ -249,9 +482,21 @@
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
}
/* Recharge la discussion du domaine courant quand il a changé
* depuis le dernier chargement (ouverture du panneau, autre
* onglet, retour du mode console). Sans changement : rien —
* on ne casse jamais une conversation en cours pour rien. */
function resynchroniserDomaine() {
if (!root || domaine() === domaineCharge) return;
msgsEl.innerHTML = "";
tiroirEl.hidden = true;
demarrerDiscussions();
}
function demarrerDiscussions() {
chargerListeDiscussions()
.then(function (d) {
domaineCharge = domaine();
var precedente = convStockee();
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
@@ -262,6 +507,7 @@
chargerHistorique();
})
.catch(function () {
domaineCharge = null; // échec : la prochaine ouverture réessaiera
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
});
}
@@ -629,19 +875,232 @@
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
}
function envoyer(ev) {
ev.preventDefault();
var texte = inputEl.value.trim();
if (!texte) return;
/* ── Composer façon console ────────────────────────────────
* Mêmes règles que la console : + (images/documents, 4 max),
* textarea auto-extensible, micro affiché seulement sans texte
* ni pièce et si le navigateur le supporte, envoi rond bleu
* affiché seulement avec texte ou pièce. */
var piecesEnAttente = [];
var TYPES_IMAGES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
var TYPES_DOCUMENTS = ["application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.oasis.opendocument.text",
"text/plain", "text/markdown", "text/html", "text/csv"];
var TAILLE_MAX_IMAGE = 5 * 1024 * 1024;
var TAILLE_MAX_DOC = 10 * 1024 * 1024;
function majComposer() {
if (!inputEl) return;
var aTexte = !!(inputEl.value && inputEl.value.trim());
sendBtn.hidden = !(aTexte || piecesEnAttente.length);
if (microEl) microEl.hidden = !dicteeSupportee || aTexte || piecesEnAttente.length;
inputEl.style.height = "auto";
var sh = inputEl.scrollHeight;
inputEl.style.height = sh ? Math.min(sh, 152) + "px" : "";
}
function rendrePiecesEnAttente() {
piecesEl.innerHTML = "";
piecesEl.hidden = !piecesEnAttente.length;
piecesEnAttente.forEach(function (p, i) {
var carte = document.createElement("div");
carte.className = "piece-carte";
var apercu;
if (TYPES_IMAGES.indexOf(p.type) >= 0) {
apercu = document.createElement("img");
apercu.src = p.dataUrl; apercu.alt = p.nom;
} else {
apercu = document.createElement("span");
apercu.className = "piece-doc-ext";
var ext = document.createElement("span");
ext.className = "piece-ext";
ext.textContent = (p.nom || "").split(".").pop() || "doc";
apercu.appendChild(ext);
}
var nom = document.createElement("span");
nom.className = "piece-nom"; nom.textContent = p.nom; nom.title = p.nom;
var x = document.createElement("button");
x.type = "button"; x.className = "piece-retirer"; x.textContent = "✕";
x.title = "Retirer " + p.nom;
x.setAttribute("aria-label", "Retirer " + p.nom);
x.addEventListener("click", function () {
piecesEnAttente.splice(i, 1);
rendrePiecesEnAttente(); majComposer();
});
carte.appendChild(apercu); carte.appendChild(nom); carte.appendChild(x);
piecesEl.appendChild(carte);
});
}
function ajouterFichiers(fichiers) {
Array.prototype.forEach.call(fichiers, function (f) {
if (piecesEnAttente.length >= 4) {
ajouterMsg("sys", "Au plus 4 pièces par message.");
return;
}
var estImage = TYPES_IMAGES.indexOf(f.type) >= 0;
var estDoc = TYPES_DOCUMENTS.indexOf(f.type) >= 0;
if (!estImage && !estDoc) {
ajouterMsg("sys", "« " + f.name + " » refusée : images PNG, JPEG, GIF ou WebP, ou documents PDF, DOCX, ODT, TXT, MD, HTML ou CSV seulement.");
return;
}
if (estImage && f.size > TAILLE_MAX_IMAGE) {
ajouterMsg("sys", "« " + f.name + " » refusée : 5 Mo maximum par image.");
return;
}
if (estDoc && f.size > TAILLE_MAX_DOC) {
ajouterMsg("sys", "« " + f.name + " » refusée : 10 Mo maximum par document.");
return;
}
var lecteur = new FileReader();
lecteur.onload = function () {
var dataUrl = String(lecteur.result || "");
piecesEnAttente.push({
nom: f.name, type: f.type,
base64: dataUrl.split(",")[1] || "", dataUrl: dataUrl
});
rendrePiecesEnAttente(); majComposer();
};
lecteur.readAsDataURL(f);
});
}
function envoyerComposer() {
var t = inputEl.value;
var pieces = piecesEnAttente;
piecesEnAttente = [];
inputEl.value = "";
ajouterMsg("user", texte);
rendrePiecesEnAttente();
majComposer();
envoyerMessage(t, pieces);
}
/* ── Dictée vocale — Web Speech API, 100 % navigateur, comme
* la console (fr-CA, résultats intérimaires en direct). */
var RecoVoc = window.SpeechRecognition || window.webkitSpeechRecognition;
var dicteeSupportee = !!RecoVoc;
var reco = null, enregEnCours = false, enregDebut = 0, enregMinuteur = null;
var texteAvantDictee = "", transcriptionFinale = "";
var modeFinDictee = "stop", dicteeErreur = null;
function demarrerDictee() {
if (!dicteeSupportee || enregEnCours) return;
texteAvantDictee = inputEl.value;
transcriptionFinale = "";
modeFinDictee = "stop";
dicteeErreur = null;
try { reco = new RecoVoc(); } catch (e) { reco = null; return; }
reco.lang = "fr-CA";
reco.interimResults = true;
reco.continuous = true;
reco.onresult = function (ev) {
var interim = "";
for (var i = ev.resultIndex; i < ev.results.length; i++) {
var r = ev.results[i];
if (r.isFinal) transcriptionFinale += r[0].transcript;
else interim += r[0].transcript;
}
var affiche = (texteAvantDictee + " " + transcriptionFinale + interim).trim();
root.querySelector(".enreg-texte").textContent = affiche;
inputEl.value = affiche;
};
reco.onerror = function (ev) {
if (ev.error === "aborted") return;
dicteeErreur = ev.error || "erreur";
modeFinDictee = "annuler";
};
reco.onend = terminerDictee;
try { reco.start(); } catch (e) { reco = null; return; }
enregEnCours = true;
enregDebut = Date.now();
root.querySelector(".enreg-temps").textContent = "0:00";
root.querySelector(".enreg-texte").textContent = "";
var onde = root.querySelector(".enreg-onde");
if (!onde.children.length) {
for (var j = 0; j < 14; j++) onde.appendChild(document.createElement("span"));
}
root.querySelector(".draco-composer-ligne").hidden = true;
root.querySelector(".draco-composer-enreg").hidden = false;
root.querySelector(".draco-composer").classList.add("en-cours");
enregMinuteur = window.setInterval(function () {
var s = Math.floor((Date.now() - enregDebut) / 1000);
root.querySelector(".enreg-temps").textContent = Math.floor(s / 60) + ":" + ("0" + (s % 60)).slice(-2);
}, 250);
}
function arreterDictee(mode) {
modeFinDictee = mode;
if (reco) {
try { reco.stop(); } catch (e) { terminerDictee(); }
} else {
terminerDictee();
}
}
function terminerDictee() {
if (!enregEnCours) return;
enregEnCours = false;
if (enregMinuteur) { window.clearInterval(enregMinuteur); enregMinuteur = null; }
reco = null;
root.querySelector(".draco-composer-ligne").hidden = false;
root.querySelector(".draco-composer-enreg").hidden = true;
root.querySelector(".draco-composer").classList.remove("en-cours");
if (modeFinDictee === "annuler") {
inputEl.value = texteAvantDictee;
if (dicteeErreur) {
ajouterMsg("sys", "Dictée vocale interrompue (" + dicteeErreur + ") — micro refusé ou indisponible ?");
dicteeErreur = null;
}
} else {
inputEl.value = (texteAvantDictee + " " + transcriptionFinale).trim();
}
var mode = modeFinDictee;
modeFinDictee = "stop";
majComposer();
if (mode === "envoyer") envoyerComposer();
else inputEl.focus();
}
function envoyer(ev) {
if (ev) ev.preventDefault();
envoyerComposer();
}
function envoyerMessage(texte, pieces) {
texte = (texte || "").trim();
pieces = pieces || [];
if (!texte && !pieces.length) return;
var el = ajouterMsg("user", texte);
if (pieces.length) {
var zone = document.createElement("div");
zone.className = "draco-msg-pieces";
pieces.forEach(function (p) {
if (TYPES_IMAGES.indexOf(p.type) >= 0) {
var img = document.createElement("img");
img.src = p.dataUrl; img.alt = p.nom;
zone.appendChild(img);
} else {
var past = document.createElement("span");
past.className = "piece-fichier";
past.textContent = p.nom;
zone.appendChild(past);
}
});
el.appendChild(zone);
}
sendBtn.disabled = true;
var attente = ajouterMsg("agent", "Spark réfléchit…");
var corps = { message: texte, domain: domaine(), conversation_id: convId };
if (pieces.length) {
corps.pieces = pieces.map(function (p) {
return { nom: p.nom, type: p.type, donnees_base64: p.base64 };
});
}
fetch(API + "/api/v1/chat", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
body: JSON.stringify(corps)
})
.then(function (r) {
if (r.status === 401) {
@@ -680,14 +1139,16 @@
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
majEtat("err", "Service d'agents injoignable");
})
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
.finally(function () { sendBtn.disabled = false; majComposer(); inputEl.focus(); });
}
function ouvrir() {
if (!root) { construire(); demarrerDiscussions(); }
else resynchroniserDomaine();
root.classList.add("open");
appliquerTheme();
verifierIdentite();
chargerVersion();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
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

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
<!-- ShaarIt — signet (bookmark) blanc sur fond émeraude #059669,
couleur signature de l'app dans la barre (#059669, distincte du
teal Kanbante #0d9488). Fond arrondi comme les logos PNG 180 px
des autres apps ; géométrie simple, aucun redessin d'un logo
existant : le signet EST le symbole de l'app (bookmarks). -->
<rect x="8" y="8" width="224" height="224" rx="48" fill="#059669"/>
<path d="M 82 52 L 158 52 L 158 190 L 120 152 L 82 190 Z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 569 B

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

+96 -88
View File
@@ -10,7 +10,7 @@
name="description"
content="Porte d'entrée web pour vos vaults Obsidian - Accédez, naviguez et recherchez dans toutes vos notes"
/>
<meta name="theme-color" content="#2563eb" />
<meta name="theme-color" content="#7c5cd6" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
@@ -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%, #1b2030 0%, #0e1117 60%);
color: #e6e8ee; font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
}
body.app-booted #boot-splash { display: none; }
.bs-card { display: flex; flex-direction: column; align-items: center; gap: 14px; }
.bs-logo {
width: 72px; height: 72px; display: flex; align-items: center; justify-content: center;
font-size: 38px; background: #171c26; border: 1px solid #2a3242; border-radius: 18px;
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,74 @@
<!-- 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
(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. -->
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.3.0
(ui/barre-apps/, branche hub-menu-compte-v0-3-0 de la PR
commons #9 — HUB-1, fusion #115) : ne pas modifier la structure
ici ; toute évolution passe par une nouvelle version de commons.
v0.3.0 : menu compte — liens Hub Télécharger / À Propos /
Aides et Profil devenu lien (vers le Hub résolu par
backend/draco_config, clé "hub", passé en urls.hub de la
configuration ci-dessous) ; « Déconnexion » reste relayée.
v0.2.0 : 6e onglet ShaarIt (data-app="shaarit", après Kanbante,
avant l'onglet agent) — son href porte le défaut officiel et
la réécriture DRACO_URLS ci-dessous le résout comme les
autres (config backend/draco_config, dérivation -dev incluse) ;
le CSS vendangé gagne la garde data-theme sur
prefers-color-scheme (html:not([data-theme])).
CSS/JS : /static/draco-barre.css, /static/draco-barre.js.
Icônes logos v0.1.9 (2026-10-29) : les 5 tuiles sont les logos
fournis, tels quels, servis depuis /static/img/logos/ (copie de
ui/barre-apps/logos/ de dracodev-commons) ; l'entrée « Console Spark »
du menu clone l'icône de l'onglet agent (draco-barre.js).
v0.1.10 (2026-10-29) : --agent rose → rouge Spark #c00000, marque ✦
→ logo Spark 26 px, bouton Spark rose supprimé (remplacé par
l'avatar flottant bas-centre /static/img/avatar-spark.png,
data-act="spark" → panneau Spark), Alertes = cloche seule.
v0.1.11 (2026-10-29) : marque → étoile Spark monochrome
/static/img/brand-spark.png 26 px (blanche sur barre foncée
via [data-theme="dark"] d'ObsiGate, noire sur barre claire
via le filtre brightness(0) du CSS vendangé — aucune
surcharge propre nécessaire, la barre suit déjà
data-theme) ; onglet agent GARDANT le logo rouge ;
anneau rouge #c00000 (bordure 3 px) autour de l'avatar
flottant, via le CSS vendangé.
Chantier 5 (fusion #69/#71, sans bump) : 5e onglet Console Spark
(data-app="agent", défaut prod ; la réécriture DRACO_URLS
couvre la clé "agent" comme les autres onglets), après Kanbante ;
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="shaarit" href="https://shaarit.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#059669" data-icon="shaarit"><img src="/static/img/logos/shaarit.svg" alt="ShaarIt" width="30" height="30"></span>
<span class="draco-apptab-txt"><strong>ShaarIt</strong><small>Bookmarks</small></span>
</a>
<a class="draco-apptab" data-app="agent" href="https://agent.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#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,26 +354,39 @@
</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. */
(function () {
if (!window.DRACO_URLS) return;
document.querySelectorAll(".draco-apptab").forEach(function (a) {
var u = window.DRACO_URLS[a.dataset.app];
if (u) a.href = u.replace(/\/$/, "") + "/";
});
})();
window.DRACO_BAR_CONFIG = {
current: "obsigate",
/* Barre v0.3.0 (HUB-1) : la base du Hub vient de
window.DRACO_URLS (clé "hub" de backend/draco_config :
DRACO_HUB_URL explicite, dérivation -dev, défaut
officiel) — le Hub n'a pas d'onglet, c'est par
urls.hub que le menu compte construit ses liens
Télécharger / À Propos / Aides / Profil. */
urls: (function () { var u = window.DRACO_URLS && window.DRACO_URLS.hub; return u ? { hub: u.replace(/\/$/, "") + "/" } : {}; })(),
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
counts: {},
onAction: function (act, el) {
@@ -421,9 +409,10 @@
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; }
if (act === "deconnexion") { location.href = "/sso/logout?return_to=" + encodeURIComponent(location.origin + "/"); return; }
/* Correctif fusion #70 : relais "menu" supprimé avec le bouton trois points de la barre (la console agent est dans le menu de la marque, entrée « Console Spark »). Le bouton natif #header-menu-btn d'ObsiGate lui-même est inchangé et reste utilisable depuis l'en-tête ObsiGate. */
if (act === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { dire("Alertes unifiées : le module n'a pas pu se charger."); } return; }
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
@@ -450,6 +439,24 @@
var av = document.getElementById("draco-avatar");
if (av) { av.textContent = nom.charAt(0).toUpperCase(); av.title = nom; }
}
/* Barre v0.6.0 (commons) : entrée « Administration »
du menu compte. Le rôle global du compte chez
ObsiGate EST la définition de l'administrateur
de suite (c'est lui que la garde /admin/* du Hub
vérifie, via ce même /api/auth/me) — ObsiGate le
connaît nativement, il l'affirme à la barre.
Tout autre rôle (ou échec) : pas d'affirmation,
l'entrée reste masquée. */
if (u && window.DRACO_BAR && window.DRACO_BAR.setAdmin) {
window.DRACO_BAR.setAdmin(u.role === "admin");
}
/* Barre v0.7.0 (commons, BUG-124) : le nom du
compte est aussi poussé par setUser — le
title seul ne resynchronisait ni l'aria-label
ni un menu compte déjà construit. */
if (u && nom && window.DRACO_BAR && window.DRACO_BAR.setUser) {
window.DRACO_BAR.setUser(nom);
}
}).catch(function () { /* avatar générique conservé */ });
});
</script>
@@ -6055,6 +6062,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>
+2 -10
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();
}
await init();
Sync.init();
// Show version in header — always visible, fallback before the async fetch
populateVersions();
fetch('/api/health')
+52 -1
View File
@@ -131,15 +131,17 @@ const AuthManager = {
_honorReturnTo() {
try {
const rt = new URLSearchParams(window.location.search).get("return_to");
if (!rt) return;
if (!rt) return false;
const u = new URL(rt);
const okHost = u.hostname === "dracodev.net"
|| u.hostname.endsWith(".dracodev.net");
if (u.protocol === "https:" && okHost
&& u.hostname !== window.location.hostname) {
window.location.replace(rt);
return true;
}
} catch (e) { /* return_to illisible : on reste sur place */ }
return false;
},
getToken() {
@@ -386,6 +388,17 @@ const AuthManager = {
const section = document.getElementById("sidebar-user");
if (!user || !this._authEnabled) {
if (section) section.hidden = true;
// Barre commune v0.6.0 (fusion #142) : sans identité
// établie, l'entrée « Administration » du menu compte
// reste masquée.
if (window.DRACO_BAR && window.DRACO_BAR.setAdmin) {
window.DRACO_BAR.setAdmin(false);
}
// Barre commune v0.7.0 (BUG-124) : symétriquement, le nom
// du compte est retiré de la barre (retour au neutre).
if (window.DRACO_BAR && window.DRACO_BAR.setUser) {
window.DRACO_BAR.setUser(null);
}
return;
}
if (!section) return;
@@ -427,6 +440,29 @@ const AuthManager = {
});
}
// Barre commune v0.6.0 (fusion #142) : l'entrée
// « Administration » du menu compte suit le rôle natif du
// compte — la définition même de l'administrateur de suite
// (garde /admin/* du Hub), même source que la rangée
// d'administration native ci-dessous. renderUserSection()
// est appelée par showApp() pour toutes les sessions
// établies (connexion, restauration, renouvellement
// silencieux) ; le fetch d'amorçage d'index.html ne couvre
// que les sessions déjà présentes au chargement de la page.
if (window.DRACO_BAR && window.DRACO_BAR.setAdmin) {
window.DRACO_BAR.setAdmin(user.role === "admin");
}
// Barre commune v0.7.0 (BUG-124) : pousser aussi le NOM du
// compte, pour le même motif — sans cet appel, la barre ne
// connaissait l'identité que par le fetch d'amorçage
// d'index.html (sessions déjà présentes au chargement) :
// après un login SPA sans rechargement, le menu compte
// affichait l'en-tête générique « Compte » et l'avatar
// neutre « ✦ » jusqu'au rechargement forcé.
if (window.DRACO_BAR && window.DRACO_BAR.setUser) {
window.DRACO_BAR.setUser(name || null);
}
const adminRow = document.getElementById("admin-menu-row");
if (adminRow) {
if (user.role === "admin") {
@@ -469,6 +505,11 @@ const AuthManager = {
// Auth enabled — check for existing session
if (this.hasSession() && !this.isTokenExpired()) {
// BUG-123 : session déjà établie au démarrage (jeton encore
// valide) — si la page a été ouverte par /sso/authorize avec
// un return_to, l'aller-retour SSO doit se refermer ici
// aussi, pas seulement après un login frais (saveToken).
if (this._honorReturnTo()) return true;
this.showApp();
return true;
}
@@ -485,6 +526,16 @@ const AuthManager = {
if (res.ok) {
const user = await res.json();
sessionStorage.setItem(this.USER_KEY, JSON.stringify(user));
// BUG-123 : même obligation après restauration par
// refresh silencieux au démarrage — refreshAccessToken()
// ne passe pas par saveToken, donc sans cet appel le
// return_to d'un /sso/authorize restait sans effet et
// l'utilisateur demeurait sur l'accueil ObsiGate au lieu
// de revenir, connecté, à l'application demanderesse.
// NB : seuls les chemins de démarrage d'initAuth honorent
// le retour ; les refresh silencieux en cours de session
// ne doivent jamais rediriger.
if (this._honorReturnTo()) return true;
this.showApp();
return true;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+206 -18
View File
@@ -4,6 +4,7 @@ import { api, AuthManager } from './auth.js';
import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
import { openCoffresView, openCoffresNewSecret, openCoffresAcces, openCoffresJournal, getCoffresForSidebar, invalidateCoffresCache } from './coffres.js';
import { performAdvancedSearch } from './search.js';
import { t } from './i18n.js';
import { filterChatMessages as _filterChat } from './filechat.js';
@@ -46,6 +47,179 @@ function _sharedIcon(size = 16) {
return i;
}
// ---------------------------------------------------------------------------
// Coffre Secrets — nœud virtuel « Secrets » sous le Home (Phase 1c, #19).
// Patron exact du dossier virtuel « Partage » (#196) : inséré à la racine
// du Home de l'utilisateur courant seulement, sans dossier sur disque, sans
// passer par l'indexeur. Contrairement à « Partage », le nœud est permanent
// (même sans coffre) : cliquer dessus ouvre la vue Secrets, qui crée le
// coffre personnel à la première ouverture si besoin.
// AUCUNE action de fichier n'est attachée à ce nœud ni à ses enfants :
// ni attachTreeItemActionButton, ni ContextMenuManager (qui proposerait
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
// ---------------------------------------------------------------------------
/** Icône coffre-fort du nœud Secrets (SVG Safe Vault Streamline, fourni par Bruno), distincte de la maison et du diamant. Le tracé utilise currentColor pour rester lisible en thème clair comme sombre (le noir d'origine serait invisible en sombre). */
function _secretsIcon() {
const s = document.createElement("span");
s.className = "coffres-icon-lock";
s.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" aria-hidden="true"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M12.5 0.5h-11c-0.552285 0 -1 0.447715 -1 1V11c0 0.5523 0.447715 1 1 1h11c0.5523 0 1 -0.4477 1 -1V1.5c0 -0.552285 -0.4477 -1 -1 -1Z"/><path d="M8.5 8c0.9665 0 1.75 -0.7835 1.75 -1.75S9.4665 4.5 8.5 4.5s-1.75 0.7835 -1.75 1.75S7.5335 8 8.5 8Z"/><path d="M8.5 3.25V4.5"/><path d="M8.5 8v1.25"/><path d="M11.5 6.25h-1.25"/><path d="M6.75 6.25H5.5"/><path d="m10.62 4.12988 -0.88001 0.88"/><path d="m7.26 7.48999 -0.88 0.88"/><path d="m10.62 8.36999 -0.88001 -0.88"/><path d="m7.26 5.00988 -0.88 -0.88"/><path d="M3 4.5V8"/><path d="M2 12v1.5"/><path d="M11.5 12v1.5"/></g></svg>';
return s;
}
function _isOwnHome(vaultName) {
const me = (AuthManager.getUser() || {}).username;
return !!me && vaultName === `home-${me}`;
}
/** Construit le nœud Secrets + son conteneur d'enfants (coffres). */
async function _buildSecretsNode(vaultName) {
const pair = document.createDocumentFragment();
const item = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Secrets", "data-type": "virtual-secrets" }, [icon("chevron-right", 14), _secretsIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("coffres.secrets"))])]);
const sub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Secrets` });
item.addEventListener("click", async () => {
scrollTreeItemIntoView(item, false);
if (sub.classList.contains("collapsed")) {
if (sub.children.length === 0) await _fillSecretsNode(item, sub, vaultName);
sub.classList.remove("collapsed");
const chev = item.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-down");
safeCreateIcons();
} else {
sub.classList.add("collapsed");
const chev = item.querySelector("[data-lucide]");
if (chev) chev.setAttribute("data-lucide", "chevron-right");
safeCreateIcons();
}
await openCoffresView();
closeMobileSidebar();
});
item.addEventListener("contextmenu", (e) => {
e.preventDefault();
_openSecretsMenu(e.clientX, e.clientY);
});
const actionBtn = el("button", { class: "tree-item-action-btn", type: "button", title: t("coffres.secrets") }, [document.createTextNode("\u22EE")]);
actionBtn.addEventListener("click", (e) => {
e.stopPropagation();
const r = actionBtn.getBoundingClientRect();
_openSecretsMenu(Math.min(r.right, window.innerWidth - 16), r.bottom);
});
item.appendChild(actionBtn);
pair.appendChild(item);
pair.appendChild(sub);
// Compteur et enfants, en arrière-plan : le nœud existe même si l'API échoue.
_fillSecretsNode(item, sub, vaultName).catch(() => {});
return pair;
}
/** Remplit le compteur et les enfants (coffres) d'un nœud Secrets existant. */
async function _fillSecretsNode(item, sub, vaultName) {
const data = await getCoffresForSidebar();
item.querySelectorAll(".badge-small, .coffres-alert-dot").forEach((n) => n.remove());
if (!data.disabled && data.personnel) {
if (!data.personnel.kek_configuree) {
const dot = document.createElement("span");
dot.className = "coffres-alert-dot";
dot.textContent = "\u26A0";
dot.title = t("coffres.kek_absente");
item.appendChild(dot);
} else {
item.appendChild(smallBadge(data.personnel.nb_secrets));
}
}
sub.innerHTML = "";
const coffres = (data.coffres || []).slice().sort((a, b) => {
const aPers = a.proprietaire === (AuthManager.getUser() || {}).username && a.perimetre === "personnel" ? 0 : 1;
const bPers = b.proprietaire === (AuthManager.getUser() || {}).username && b.perimetre === "personnel" ? 0 : 1;
return aPers - bPers || String(a.nom).localeCompare(String(b.nom));
});
coffres.forEach((c) => {
const me = (AuthManager.getUser() || {}).username;
const label = c.proprietaire === me ? c.nom : `${c.nom} (${c.proprietaire})`;
const child = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": `Secrets/${c.slug}`, "data-type": "virtual-coffre", "data-coffre-slug": c.slug }, [icon("lock", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(label)]), smallBadge(c.nb_secrets)]);
child.addEventListener("click", async () => {
scrollTreeItemIntoView(child, false);
await openCoffresView(c.slug);
closeMobileSidebar();
});
child.addEventListener("contextmenu", (e) => {
e.preventDefault();
_openSecretsMenu(e.clientX, e.clientY);
});
sub.appendChild(child);
});
safeCreateIcons();
}
/** Rafraîchit tous les nœuds Secrets affichés (après création, rotation, révocation…). */
async function _refreshSecretsNodes() {
const items = Array.from(document.querySelectorAll('[data-type="virtual-secrets"]'));
for (const item of items) {
const vaultName = item.dataset.vault;
const sub = item.nextElementSibling;
if (sub && sub.classList.contains("tree-children")) {
await _fillSecretsNode(item, sub, vaultName);
}
}
}
if (!window.__obsigateSecretsListener) {
window.__obsigateSecretsListener = true;
document.addEventListener("obsigate:coffres-changed", () => {
invalidateCoffresCache();
_refreshSecretsNodes().catch(() => {});
});
}
/** Menu ⋮ du nœud Secrets : Ouvrir, Nouveau secret, Gérer les accès, Journal.
* Menu propre, jamais ContextMenuManager (pas d'action de fichier). */
function _openSecretsMenu(x, y) {
document.querySelectorAll(".coffres-context-menu").forEach((n) => n.remove());
const menu = el("div", { class: "coffres-context-menu" }, []);
const entries = [
[t("coffres.detail"), () => openCoffresView()],
[t("coffres.new_secret"), () => openCoffresNewSecret()],
[t("coffres.acces"), () => openCoffresAcces()],
[t("coffres.journal"), () => openCoffresJournal()],
];
entries.forEach(([label, run]) => {
const b = el("button", { class: "coffres-context-item", type: "button" }, [document.createTextNode(label)]);
b.addEventListener("click", async () => {
menu.remove();
await run();
closeMobileSidebar();
});
menu.appendChild(b);
});
document.body.appendChild(menu);
const rect = menu.getBoundingClientRect();
menu.style.left = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8)) + "px";
menu.style.top = Math.max(8, Math.min(y, window.innerHeight - rect.height - 8)) + "px";
const closer = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener("click", closer, true);
document.removeEventListener("keydown", onKey);
}
};
const onKey = (e) => { if (e.key === "Escape") { menu.remove(); document.removeEventListener("click", closer, true); document.removeEventListener("keydown", onKey); } };
setTimeout(() => {
document.addEventListener("click", closer, true);
document.addEventListener("keydown", onKey);
}, 0);
}
/**
* 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();
@@ -193,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);
@@ -297,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);
@@ -433,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}` });
@@ -461,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) {
@@ -486,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);
@@ -494,6 +668,12 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
}
});
// Coffre Secrets (Phase 1c) : même nœud virtuel que dans loadDirectory.
if (me && vaultName === `home-${me}` && !dirPath) {
const pair = await _buildSecretsNode(vaultName);
if (pair) fragment.insertBefore(pair, fragment.firstChild);
}
// Replace container content in a single batch operation to avoid flash
container.textContent = "";
container.appendChild(fragment);
@@ -775,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}` });
@@ -803,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 {
@@ -811,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) {
@@ -830,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);
});
@@ -838,6 +1018,14 @@ async function loadDirectory(vaultName, dirPath, container) {
}
});
// Coffre Secrets (Phase 1c) : nœud virtuel permanent en tête, à la racine
// du Home de l'utilisateur courant seulement, jamais sous un autre Home
// ni sous un vault de notes, et jamais comme un dossier physique.
if (me && vaultName === `home-${me}` && !dirPath) {
const pair = await _buildSecretsNode(vaultName);
if (pair) fragment.insertBefore(pair, fragment.firstChild);
}
container.appendChild(fragment);
safeCreateIcons();
}
+68 -3
View File
@@ -1,5 +1,5 @@
/* ObsiGate — Theme System
* 15 themes x 2 modes (dark/light)
* 16 themes x 2 modes (dark/light)
* Persisted in localStorage as 'obsigate-theme'
* Usage: import { initThemes, applyTheme, getCurrentTheme, THEMES } from './themes.js'
*/
@@ -45,6 +45,71 @@ var THEMES = {
}
},
// ── Thème Spark ──────────────────────────────────────────────────────
// Palette source : console Spark (agent.dracodev.net), app/console/console.css
// (variables --p-*). Mapping Spark -> ObsiGate, identique en structure pour
// dark et light ; seules les valeurs changent (sauf accent/ok/warn/err).
// Fidèle (valeur Spark directe) :
// fond -> --bg-primary, --surface2 ; surface -> --surface ;
// chip -> --bg-secondary, --tag-bg ; chip-survol -> --bg-hover ;
// bord -> --border ; chip-bord -> --border-md, --scrollbar, --resize-handle ;
// texte -> --text-primary, --text ; doux -> --text-secondary, --text-2 ;
// faint -> --text-muted, --text-3 ; champ -> --search-bg ;
// code -> --code-bg ; accent -> --accent, --accent-card, --purple, --ai ;
// lien -> --accent-text (variante lisible de l'accent sur fond teinté) ;
// ok -> --accent-green, --success, --green ;
// danger -> --danger ; danger-fond -> --danger-bg ;
// pastilles ok (fond) -> --success-bg, --green-bg ;
// pastilles ok (texte) -> --ok ; ambre -> --warn ; rouge -> --err.
// Approximations documentées (ObsiGate a plus de variables que Spark,
// ce ne sont PAS des valeurs Spark) :
// --bg-sidebar <- fond (Spark n'a pas de token sidebar dédié) ;
// --accent-bg <- agent (surface teintée de Spark, pas de teinte lavande) ;
// --surface3 <- agent ; --purple-bg <- appro-fond (fond teinté le plus
// proche, la palette Spark n'a pas de teinte lavande) ;
// --overlay-bg <- convention ObsiGate (rgba noir 0.3 clair / 0.5 sombre,
// aucun token voile dans la palette Spark).
// Non reportés (aucune variable ObsiGate correspondante dans les 39) :
// bulle utilisateur/envoi #2f6fed, titres approbation #9a6a1c / #e8bc7a.
// Une seule entrée 'spark' : dark et light sont ses deux modes, jamais
// deux thèmes séparés. Modes high-contrast/sepia générés comme pour les
// autres thèmes (à partir de dark/light).
'spark': {
name: 'Spark', desc: 'Palette de la console Spark',
modes: {
dark: {
'--bg-primary': '#17181a', '--bg-secondary': '#26272b', '--bg-sidebar': '#17181a',
'--bg-hover': '#2e3036', '--border': '#33353a', '--text-primary': '#eceeeb',
'--text-secondary': '#a6aba0', '--text-muted': '#83887e', '--accent': '#7c5cd6',
'--accent-green': '#2ea36b', '--tag-bg': '#26272b', '--tag-text': '#7c5cd6',
'--code-bg': '#2c2e33', '--search-bg': '#1f2023', '--scrollbar': '#3a3c41',
'--resize-handle': '#3a3c41', '--overlay-bg': 'rgba(0,0,0,0.5)', '--danger': '#e8a08d',
'--danger-bg': '#40241f', '--success': '#2ea36b', '--success-bg': '#173d29',
'--accent-card': '#7c5cd6', '--accent-bg': '#26272b', '--accent-text': '#b39df5',
'--green': '#2ea36b', '--green-bg': '#173d29', '--purple': '#7c5cd6',
'--purple-bg': '#2a2118', '--surface': '#1f2023', '--surface2': '#17181a',
'--surface3': '#26272b', '--border-md': '#3a3c41', '--text': '#eceeeb',
'--text-2': '#a6aba0', '--text-3': '#83887e', '--ok': '#7fd6a8',
'--warn': '#e8a13c', '--ai': '#7c5cd6', '--err': '#cf4b32'
},
light: {
'--bg-primary': '#fbfbfa', '--bg-secondary': '#f4f5f1', '--bg-sidebar': '#fbfbfa',
'--bg-hover': '#e9ebe4', '--border': '#e4e5e0', '--text-primary': '#242624',
'--text-secondary': '#6c7268', '--text-muted': '#8a8f84', '--accent': '#7c5cd6',
'--accent-green': '#2ea36b', '--tag-bg': '#f4f5f1', '--tag-text': '#7c5cd6',
'--code-bg': '#e8eae3', '--search-bg': '#ffffff', '--scrollbar': '#d6d8d1',
'--resize-handle': '#d6d8d1', '--overlay-bg': 'rgba(0,0,0,0.3)', '--danger': '#a03a28',
'--danger-bg': '#f6e3de', '--success': '#2ea36b', '--success-bg': '#dff2e7',
'--accent-card': '#7c5cd6', '--accent-bg': '#eef0ea', '--accent-text': '#5b3df0',
'--green': '#2ea36b', '--green-bg': '#dff2e7', '--purple': '#7c5cd6',
'--purple-bg': '#fdf6ec', '--surface': '#ffffff', '--surface2': '#fbfbfa',
'--surface3': '#eef0ea', '--border-md': '#d6d8d1', '--text': '#242624',
'--text-2': '#6c7268', '--text-3': '#8a8f84', '--ok': '#1d7a4c',
'--warn': '#e8a13c', '--ai': '#7c5cd6', '--err': '#cf4b32'
}
}
},
'github': {
name: 'GitHub', desc: 'Contraste doux',
modes: {
@@ -523,7 +588,7 @@ var THEMES = {
};
// ── Theme Engine ─────────────────────────────────────────────────────
var _currentTheme = 'defaut-obsigate';
var _currentTheme = 'spark';
var _currentMode = 'dark';
var AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia'];
@@ -588,7 +653,7 @@ function applyTheme(themeKey, mode) {
root.style.setProperty(key, vars[key]);
});
// #180 — the spreadsheet grid follows the active theme: derive its tokens
// from the generic surfaces so every theme (15 built-in × 4 modes, custom
// from the generic surfaces so every theme (16 built-in × 4 modes, custom
// imports) gets a matching grid instead of the hardcoded dark/light pair.
// color-mix keeps the zebra stripe subtle on both dark and light grounds.
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
+106 -1
View File
@@ -2666,5 +2666,110 @@
"guide105.dl_md_title": "Download this guide as Markdown",
"guide105.dl_pdf_title": "Download this guide as PDF",
"guide105.export_title": "ObsiGate User Guide",
"guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application."
"guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application.",
"coffres.acces": "Access and consumers",
"coffres.actions": "Actions",
"coffres.active": "Active",
"coffres.confirm_dek_rotate": "Rotate this vault's DEK? Secrets will be re-encrypted, with no change for consumers.",
"coffres.confirm_delete": "Delete this secret? The following grants will break:",
"coffres.confirm_revoke_consumer": "Revoke this consumer? All of its distributions will be denied.",
"coffres.confirm_revoke_octroi": "Revoke this grant? Access will be denied on the next call.",
"coffres.consumer_no_distribution": "No distribution in the recent log.",
"coffres.consumers": "Consumers",
"coffres.copied": "Copied.",
"coffres.copy_failed": "Copy failed.",
"coffres.copy_generated": "Copy the generated value",
"coffres.copy_reference": "Copy",
"coffres.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",
"coffres.created": "Created",
"coffres.dek_rotate": "Rotate DEK",
"coffres.delete": "Delete",
"coffres.deleted": "Secret deleted.",
"coffres.description": "Description",
"coffres.detail": "Details",
"coffres.disabled": "Secret vaults are disabled on this instance (OBSIGATE_SECRETS_ENABLED).",
"coffres.expiration": "Expiration",
"coffres.expired": "Expired",
"coffres.expires": "Expires",
"coffres.expiring_soon": "Expiring soon",
"coffres.generate": "Generate",
"coffres.generate_unavailable": "Generation is unavailable in this browser.",
"coffres.grant_all_coffre": "Whole vault",
"coffres.journal": "Access log",
"coffres.kek_absente": "Master key missing: metadata is visible, but no secret can be created or distributed.",
"coffres.kek_generate": "Generate a KEK",
"coffres.kek_generate_hint": "The generated KEK is returned only once and is never stored by ObsiGate: back it up outside the repository immediately.",
"coffres.kek_new": "New KEK",
"coffres.kek_ok": "Master key configured.",
"coffres.kek_once_hint": "Copy it now and back it up outside the repository: it will never be shown again.",
"coffres.kek_rotate": "Rotate KEK",
"coffres.kek_rotated": "KEK rotated. Remember to replace the KEK in the environment or KEK file.",
"coffres.kek_rotation_hint": "After rotation, replace the KEK in the environment or KEK file, otherwise distributions will fail.",
"coffres.last_use": "Last use",
"coffres.loading": "Loading vaults…",
"coffres.manual_copy": "One-time manual copy",
"coffres.manual_copy_admin_only": "Admin only (no session-based distribution endpoint exists yet on the backend).",
"coffres.manual_copy_confirm": "A temporary consumer will be created, the value will be copied to the clipboard without being shown, then the consumer will be revoked immediately. The distribution will be audited.",
"coffres.modified": "Modified",
"coffres.name": "Name",
"coffres.name_hint": "Lowercase, digits, - or _ ; 3 to 128 characters (e.g. gitea-token).",
"coffres.name_required": "A name is required.",
"coffres.never_in_note": "Never paste the value into the note.",
"coffres.new_coffre": "New vault",
"coffres.new_consumer": "New consumer",
"coffres.new_secret": "New secret",
"coffres.no_coffre": "No visible vault.",
"coffres.no_secrets": "No secret in this vault.",
"coffres.note": "Note",
"coffres.octroi_created": "Grant created.",
"coffres.octroi_default_90": "Empty = default expiration (90 days).",
"coffres.octroi_no_expiration": "No expiration",
"coffres.octroi_revoked": "Grant revoked.",
"coffres.octrois": "Grants",
"coffres.perimetre": "Scope",
"coffres.propose_octroi": "Propose a grant (optional)",
"coffres.reference": "Reference",
"coffres.revoke": "Revoke",
"coffres.rotated": "Secret rotated: the new version is active.",
"coffres.rotation": "Rotate",
"coffres.rotation_title": "Rotate secret",
"coffres.save": "Save",
"coffres.saved": "Secret saved. The value will no longer be shown.",
"coffres.search_placeholder": "Filter secrets…",
"coffres.secret": "Secret",
"coffres.secret_saved_octroi_failed": "Secret saved, but the grant failed",
"coffres.secrets": "Secrets",
"coffres.select_secret": "Select a secret to see its metadata.",
"coffres.service": "Service",
"coffres.show_before_save": "Show before saving",
"coffres.slug": "Slug",
"coffres.slug_hint": "Lowercase, digits, - or _ ; 3 to 64 characters.",
"coffres.status": "Status",
"coffres.subtitle": "Secret vaults — metadata only, a value is never shown again after it is saved.",
"coffres.target_id": "Target id",
"coffres.target_id_required": "A target id is required.",
"coffres.target_type": "Target type",
"coffres.title": "Secrets",
"coffres.token_once": "Token (shown only once)",
"coffres.token_once_hint": "Copy it now: it will never be shown again.",
"coffres.type": "Type",
"coffres.value": "Value",
"coffres.value_required": "A value is required.",
"coffres.version": "Version",
"coffres.import_existing": "Import existing",
"coffres.import_title": "Import existing keys",
"coffres.import_has_value": "Value present",
"coffres.import_no_value": "No value",
"coffres.import_already_exists": "Already in the vault",
"coffres.import_migrated": "Migrated",
"coffres.import_warning": "The AI and tool keys from data/api_keys.json will be copied into the instance vault “ObsiGate interne”. Import deletes nothing. Clearing the plaintext is a separate step, to confirm explicitly — note: the current readers (AI, tools) still read api_keys.json, so clearing deprives them of the key until their reader is wired to the vault.",
"coffres.import_done": "Import finished",
"coffres.import_purge": "Delete plaintext",
"coffres.import_purge_confirm": "Permanently delete the following migrated keys from plaintext? This cannot be undone without restoring the file:",
"coffres.import_purged": "Plaintext deleted"
}
+106 -1
View File
@@ -2666,5 +2666,110 @@
"guide105.dl_md_title": "Télécharger ce guide en Markdown",
"guide105.dl_pdf_title": "Télécharger ce guide en PDF",
"guide105.export_title": "Guide d'utilisation ObsiGate",
"guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application."
"guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application.",
"coffres.acces": "Accès et consommateurs",
"coffres.actions": "Actions",
"coffres.active": "Actif",
"coffres.confirm_dek_rotate": "Pivoter la DEK de ce coffre ? Les secrets seront re-chiffrés, sans changement pour les consommateurs.",
"coffres.confirm_delete": "Supprimer ce secret ? Les octrois suivants seront cassés :",
"coffres.confirm_revoke_consumer": "Révoquer ce consommateur ? Toutes ses distributions seront refusées.",
"coffres.confirm_revoke_octroi": "Révoquer cet octroi ? L'accès sera refusé dès le prochain appel.",
"coffres.consumer_no_distribution": "Aucune distribution dans le journal récent.",
"coffres.consumers": "Consommateurs",
"coffres.copied": "Copié.",
"coffres.copy_failed": "Copie impossible.",
"coffres.copy_generated": "Copier la valeur générée",
"coffres.copy_reference": "Copier",
"coffres.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",
"coffres.created": "Créé",
"coffres.dek_rotate": "Rotation DEK",
"coffres.delete": "Supprimer",
"coffres.deleted": "Secret supprimé.",
"coffres.description": "Description",
"coffres.detail": "Détail",
"coffres.disabled": "Les coffres de secrets sont désactivés sur cette instance (OBSIGATE_SECRETS_ENABLED).",
"coffres.expiration": "Expiration",
"coffres.expired": "Expiré",
"coffres.expires": "Expire",
"coffres.expiring_soon": "Expire bientôt",
"coffres.generate": "Générer",
"coffres.generate_unavailable": "Génération indisponible dans ce navigateur.",
"coffres.grant_all_coffre": "Tout le coffre",
"coffres.journal": "Journal d'accès",
"coffres.kek_absente": "Clé maître absente : les métadonnées sont visibles, mais aucun secret ne peut être créé ou distribué.",
"coffres.kek_generate": "Générer une KEK",
"coffres.kek_generate_hint": "La KEK générée n'est rendue qu'une fois et n'est jamais stockée par ObsiGate : sauvegardez-la hors dépôt immédiatement.",
"coffres.kek_new": "Nouvelle KEK",
"coffres.kek_ok": "Clé maître configurée.",
"coffres.kek_once_hint": "Copiez-la maintenant et sauvegardez-la hors dépôt : elle ne sera plus jamais affichée.",
"coffres.kek_rotate": "Rotation KEK",
"coffres.kek_rotated": "KEK pivotée. Pensez à remplacer la KEK dans l'environnement ou le fichier KEK.",
"coffres.kek_rotation_hint": "Après la rotation, remplacez la KEK dans l'environnement ou le fichier KEK, sinon les distributions échoueront.",
"coffres.last_use": "Dernière utilisation",
"coffres.loading": "Chargement des coffres…",
"coffres.manual_copy": "Copie manuelle à usage unique",
"coffres.manual_copy_admin_only": "Réservée aux administrateurs (aucun endpoint de distribution par session n'existe encore côté backend).",
"coffres.manual_copy_confirm": "Un consommateur temporaire sera créé, la valeur sera copiée au presse-papiers sans être affichée, puis le consommateur sera révoqué immédiatement. La distribution sera auditée.",
"coffres.modified": "Modifié",
"coffres.name": "Nom",
"coffres.name_hint": "Minuscules, chiffres, - ou _ ; 3 à 128 caractères (ex. gitea-token).",
"coffres.name_required": "Un nom est requis.",
"coffres.never_in_note": "Ne collez jamais la valeur dans la note.",
"coffres.new_coffre": "Nouveau coffre",
"coffres.new_consumer": "Nouveau consommateur",
"coffres.new_secret": "Nouveau secret",
"coffres.no_coffre": "Aucun coffre visible.",
"coffres.no_secrets": "Aucun secret dans ce coffre.",
"coffres.note": "Note",
"coffres.octroi_created": "Octroi créé.",
"coffres.octroi_default_90": "Vide = expiration par défaut (90 jours).",
"coffres.octroi_no_expiration": "Sans expiration",
"coffres.octroi_revoked": "Octroi révoqué.",
"coffres.octrois": "Octrois",
"coffres.perimetre": "Périmètre",
"coffres.propose_octroi": "Proposer un octroi (optionnel)",
"coffres.reference": "Référence",
"coffres.revoke": "Révoquer",
"coffres.rotated": "Secret pivoté : la nouvelle version est active.",
"coffres.rotation": "Rotation",
"coffres.rotation_title": "Rotation du secret",
"coffres.save": "Enregistrer",
"coffres.saved": "Secret enregistré. La valeur ne sera plus affichée.",
"coffres.search_placeholder": "Filtrer les secrets…",
"coffres.secret": "Secret",
"coffres.secret_saved_octroi_failed": "Secret enregistré, mais l'octroi a échoué",
"coffres.secrets": "Secrets",
"coffres.select_secret": "Sélectionnez un secret pour voir ses métadonnées.",
"coffres.service": "Service",
"coffres.show_before_save": "Afficher avant l'enregistrement",
"coffres.slug": "Identifiant (slug)",
"coffres.slug_hint": "Minuscules, chiffres, - ou _ ; 3 à 64 caractères.",
"coffres.status": "Statut",
"coffres.subtitle": "Coffres de secrets — métadonnées seules, aucune valeur n'est jamais affichée après l'enregistrement.",
"coffres.target_id": "Identifiant de la cible",
"coffres.target_id_required": "L'identifiant de la cible est requis.",
"coffres.target_type": "Type de cible",
"coffres.title": "Secrets",
"coffres.token_once": "Jeton (affiché une seule fois)",
"coffres.token_once_hint": "Copiez-le maintenant : il ne sera plus jamais affiché.",
"coffres.type": "Type",
"coffres.value": "Valeur",
"coffres.value_required": "Une valeur est requise.",
"coffres.version": "Version",
"coffres.import_existing": "Importer l'existant",
"coffres.import_title": "Importer les clés existantes",
"coffres.import_has_value": "Valeur présente",
"coffres.import_no_value": "Aucune valeur",
"coffres.import_already_exists": "Déjà dans le coffre",
"coffres.import_migrated": "Migré",
"coffres.import_warning": "Les clés IA et clés d'outils de data/api_keys.json seront copiées dans le coffre d'instance « ObsiGate interne ». L'import ne supprime rien. La purge du clair est une étape séparée, à confirmer explicitement — attention : les lecteurs actuels (IA, outils) lisent encore api_keys.json, la purge les prive donc de la clé tant que leur lecteur n'est pas branché sur le coffre.",
"coffres.import_done": "Import terminé",
"coffres.import_purge": "Supprimer le clair",
"coffres.import_purge_confirm": "Supprimer définitivement du clair les clés migrées suivantes ? Cette action est irréversible sans restauration du fichier :",
"coffres.import_purged": "Clair supprimé"
}
+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; }
+209
View File
@@ -0,0 +1,209 @@
<!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.2.0 + chantier 5 fusion #69/#71, sans
bump) : les onglets sont des liens réels, le bouton central
replace le curseur dans la boîte (recherche.js,
clic data-act="palette"). Re-vendoring alignement recherche :
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="shaarit" href="{{URL_SHAARIT}}">
<span class="draco-apptab-ico" style="--app:#059669" data-icon="shaarit"><img src="/static/img/logos/shaarit.svg" alt="ShaarIt" width="30" height="30"></span>
<span class="draco-apptab-txt"><strong>ShaarIt</strong><small>Bookmarks</small></span>
</a>
<a class="draco-apptab" data-app="agent" href="{{URL_AGENT}}">
<span class="draco-apptab-ico" style="--app:#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}}"
data-base-shaarit="{{URL_SHAARIT}}">
<h1>Rechercher partout</h1>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante, bookmarks ShaarIt — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value=""
placeholder="Rechercher partout…" autocomplete="off" autofocus>
</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="shaarit">ShaarIt <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="console">Console Spark <span class="rp-tab-count"></span></button>
</nav>
<section class="rp-section" data-rp-section="obsigate">
<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="shaarit">
<div class="rp-head"><h2>ShaarIt — bookmarks</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="console">
<div class="rp-head"><h2>Console Spark — discussions</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</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>
+92 -45
View File
@@ -11,58 +11,58 @@
/* ── Editor save dot state colors ── */
:root {
--ok: #34d399;
--warn: #fbbf24;
--ai: #8b5cf6;
--err: #f87171;
--ok: #7fd6a8;
--warn: #e8a13c;
--ai: #7c5cd6;
--err: #cf4b32;
}
/* ===== THEME — DARK (fallback default, no data-theme) ===== */
:root {
--bg-primary: #0f1117;
--bg-secondary: #161b22;
--bg-sidebar: #13161d;
--bg-hover: #1f2430;
--border: #21262d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #484f58;
--accent: #58a6ff;
--accent-green: #3fb950;
--tag-bg: #1f2a3a;
--tag-text: #79c0ff;
--code-bg: #161b22;
--search-bg: #21262d;
--scrollbar: #30363d;
--resize-handle: #30363d;
--bg-primary: #17181a;
--bg-secondary: #26272b;
--bg-sidebar: #17181a;
--bg-hover: #2e3036;
--border: #33353a;
--text-primary: #eceeeb;
--text-secondary: #a6aba0;
--text-muted: #83887e;
--accent: #7c5cd6;
--accent-green: #2ea36b;
--tag-bg: #26272b;
--tag-text: #7c5cd6;
--code-bg: #2c2e33;
--search-bg: #1f2023;
--scrollbar: #3a3c41;
--resize-handle: #3a3c41;
--overlay-bg: rgba(0, 0, 0, 0.5);
--danger: #ff7b72;
--danger-bg: #3d1a18;
--success: #3fb950;
--success-bg: #1a3d1f;
--accent-card: #58a6ff;
--accent-bg: #1f2a3a;
--accent-text: #79c0ff;
--green: #3fb950;
--green-bg: #1a3d1f;
--purple: #a371f7;
--purple-bg: #2d1b4e;
--surface: #161b22;
--surface2: #0f1117;
--surface3: #0d0f14;
--border-md: #30363d;
--text: #e6edf3;
--text-2: #8b949e;
--text-3: #484f58;
--danger: #e8a08d;
--danger-bg: #40241f;
--success: #2ea36b;
--success-bg: #173d29;
--accent-card: #7c5cd6;
--accent-bg: #26272b;
--accent-text: #b39df5;
--green: #2ea36b;
--green-bg: #173d29;
--purple: #7c5cd6;
--purple-bg: #2a2118;
--surface: #1f2023;
--surface2: #17181a;
--surface3: #26272b;
--border-md: #3a3c41;
--text: #eceeeb;
--text-2: #a6aba0;
--text-3: #83887e;
--mono: "JetBrains Mono", monospace;
--radius: 6px;
--radius-lg: 10px;
/* #154-A1 — data-grid tokens: the sheet reads slightly lighter than the
chrome (toolbar/panels), headers are clearly distinct from cells. */
--grid-bg: #161b22;
--grid-header-bg: #1f2430;
--grid-header-text: #e6edf3;
--grid-border: #3d444d;
--grid-bg: #1f2023;
--grid-header-bg: #2e3036;
--grid-header-text: #eceeeb;
--grid-border: #33353a;
--grid-zebra: rgba(255, 255, 255, 0.025);
}
@@ -7700,6 +7700,8 @@ body.popup-mode .content-area {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
box-sizing: border-box;
opacity: 0;
pointer-events: none;
transition: opacity 200ms ease;
@@ -7715,8 +7717,16 @@ body.popup-mode .content-area {
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
min-width: 400px;
max-width: 600px;
/* Correctif Phase 2 (modale de création de secret coupée en haut) :
sans max-height ni scroll interne, un formulaire long dépassait le
viewport. La modale est désormais bornée au viewport, en colonne
flex, avec seul le corps qui défile ; l'en-tête et le pied restent
toujours visibles. Sur petit écran, la largeur est bornée aussi. */
min-width: min(400px, calc(100vw - 32px));
max-width: min(600px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
display: flex;
flex-direction: column;
transform: scale(0.9);
transition: transform 200ms ease;
}
@@ -7731,6 +7741,7 @@ body.popup-mode .content-area {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.obsigate-modal-title {
@@ -7742,6 +7753,8 @@ body.popup-mode .content-area {
.obsigate-modal-body {
padding: 20px;
overflow-y: auto;
min-height: 0;
}
.obsigate-modal-footer {
@@ -7750,6 +7763,7 @@ body.popup-mode .content-area {
display: flex;
gap: 10px;
justify-content: flex-end;
flex-shrink: 0;
}
.modal-input {
@@ -8479,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 {
@@ -14331,3 +14345,36 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
color: var(--text-primary); font: inherit; font-size: 13.5px;
}
.draco-liens-erreur { color: var(--err); font-size: 13px; margin: 8px 0 0; }
/* ---------------------------------------------------------------------------
* Coffre Secrets (Phase 1c, issue #19) — vue Secrets + nœud sidebar.
* Réutilise les variables de thème existantes ; aucune valeur de secret
* n'est jamais affichée par ces styles (colonne valeur = •••••• en texte).
* --------------------------------------------------------------------------- */
.coffres-view { padding: 16px 20px; max-width: 1100px; }
.coffres-subtitle { color: var(--text-muted); margin: 0 0 12px; }
.coffres-banner { padding: 10px 12px; margin: 12px 0; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.coffres-banner.warn { border-color: var(--warning, #f59e0b); }
.coffres-chips { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
.coffres-chip { white-space: nowrap; }
.coffres-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0; }
.coffres-actions .modal-input { flex: 1 1 220px; }
.coffres-table { width: 100%; border-collapse: collapse; margin: 12px 0; }
.coffres-table th, .coffres-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border, #21262d); vertical-align: top; }
.coffres-table tbody tr { cursor: pointer; }
.coffres-table tbody tr:hover { background: var(--bg-hover, #1f2430); }
.coffres-detail { margin-top: 16px; padding: 14px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; }
.coffres-dl { display: grid; grid-template-columns: 160px 1fr; gap: 4px 12px; margin: 12px 0; }
.coffres-dl dt { color: var(--text-muted); }
.coffres-dl dd { margin: 0; overflow-wrap: anywhere; }
.coffres-line { padding: 6px 0; border-bottom: 1px solid var(--border, #21262d); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: space-between; }
.coffres-empty { color: var(--text-muted); }
.coffres-alerts { margin: 12px 0; }
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; }
.coffres-icon-lock { display: inline-flex; align-items: center; line-height: 1; }
.coffres-icon-lock svg { width: 16px; height: 16px; flex: none; }
.coffres-alert-dot { color: var(--warning, #f59e0b); font-weight: 700; margin-left: 4px; }
.coffres-context-menu { position: fixed; z-index: 1000; min-width: 220px; background: var(--bg-secondary, #161b22); border: 1px solid var(--border, #21262d); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); }
.coffres-context-item { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
.coffres-context-item:hover { background: var(--bg-hover, #1f2430); }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.76.1",
"version": "2.86.2",
"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) {

Some files were not shown because too many files have changed in this diff Show More