Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcec510451 | ||
|
|
cb468b0f3c | ||
|
|
6713510ce9 | ||
|
|
bf5b32de16 | ||
|
|
2a1780b43d | ||
|
|
be05ec99b5 | ||
|
|
3a42bc034d | ||
|
|
27e7664ef0 | ||
|
|
ccbf1d3269 | ||
|
|
0ca14739f9 | ||
|
|
d9192ef355 | ||
|
|
4c9bfd576c | ||
|
|
20898cade7 | ||
|
|
2c12639499 | ||
|
|
7d2b2067ef | ||
|
|
0b52e6ee86 | ||
|
|
cafac69008 | ||
|
|
871875b8e5 | ||
|
|
e5ae3e48d6 | ||
|
|
76de3dd75e | ||
|
|
687427381d | ||
|
|
e418d87802 | ||
|
|
a1f3690085 | ||
|
|
0a7bed10bf | ||
|
|
32996ce9aa | ||
|
|
b42265b41e | ||
|
|
0fc7654f6c | ||
|
|
effad97472 | ||
|
|
53d98bfffe | ||
|
|
62aa6079be | ||
|
|
7d48f65ff9 | ||
|
|
3acadf6ad7 | ||
|
|
5492f99f06 | ||
|
|
71221e055b | ||
|
|
86330e8f65 | ||
|
|
f3b507b8d9 | ||
|
|
c01c7e862d | ||
|
|
99cd765997 | ||
|
|
5266506b56 | ||
|
|
682a5030d3 | ||
|
|
fc3d847d85 | ||
|
|
82756030c6 | ||
|
|
0352451ead | ||
|
|
593cf492c3 | ||
|
|
0b8ad8b0f3 | ||
|
|
111d927ece | ||
|
|
2dae023ee3 | ||
|
|
b12122f78a | ||
|
|
0c7b3162c8 | ||
|
|
d4d050297b | ||
|
|
8d768bf9d5 | ||
|
|
47ac8bf6c0 | ||
|
|
01572736c0 | ||
|
|
b6e5f01748 | ||
|
|
90da0ac990 | ||
|
|
bc26d1506b | ||
|
|
769e3ca559 | ||
|
|
b5d789f02a | ||
|
|
c8dce25e0d | ||
|
|
b322e2d24a | ||
|
|
414ec69c0a | ||
|
|
f5ca656c04 | ||
|
|
e158232aba | ||
|
|
632871ac24 | ||
|
|
7af7b2c8c2 | ||
|
|
97132770e4 | ||
|
|
683ad3ef94 | ||
|
|
604383f51c | ||
|
|
bdeb2f00b3 | ||
|
|
8688875fae | ||
|
|
a42271c7de | ||
|
|
cf4685eaf1 | ||
|
|
5023fb6774 | ||
|
|
04eb70cbe4 | ||
|
|
4dc010f54d | ||
|
|
f7e36bc44e | ||
|
|
a9ac1c06a7 | ||
|
|
9bf6e7492f | ||
|
|
14070cef43 | ||
|
|
6b1abbb50e | ||
|
|
46c641ebc5 | ||
|
|
2d036f5126 | ||
|
|
2c69e357b8 | ||
|
|
ba81f22d40 | ||
|
|
070add4e7b | ||
|
|
847b3225b2 | ||
|
|
3ae5c7efac | ||
|
|
4966c27548 |
@@ -132,3 +132,13 @@ DEEPSEEK_MODEL=deepseek-chat
|
|||||||
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
|
# DRACO_SESSION_SECRET_PREV=<ancien secret, fenêtre de rotation>
|
||||||
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
|
# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net
|
||||||
# DRACO_SSO_MACHINE_KEY=<clé machine des révocations, jamais commitée>
|
# 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.
|
||||||
|
|||||||
+29
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
|||||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
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
|
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||||
> [Unreleased](#unreleased). La dernière version livrée est **2.79.0**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.81.0**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,34 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
## [2.79.0] — 2026-10-26
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.79.0).
|
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.81.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.79.0 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.81.0 | Dernière mise à jour : Septembre 2026*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.79.0).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.81.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.79.0 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.81.0 | Last updated: September 2026*
|
||||||
|
|||||||
+2
-2
@@ -10,7 +10,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ def _build_stats_payload() -> dict:
|
|||||||
"disk_used_gb": disk_used_gb,
|
"disk_used_gb": disk_used_gb,
|
||||||
"disk_total_gb": disk_total_gb,
|
"disk_total_gb": disk_total_gb,
|
||||||
"uptime_seconds": int(_server_uptime_seconds()),
|
"uptime_seconds": int(_server_uptime_seconds()),
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+7
-7
@@ -10,7 +10,7 @@ Format: JSON lines (one JSON object per line) for easy parsing.
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.audit")
|
logger = logging.getLogger("obsigate.audit")
|
||||||
@@ -54,7 +54,7 @@ def log_file_save(
|
|||||||
):
|
):
|
||||||
"""Log a file save (PUT) operation."""
|
"""Log a file save (PUT) operation."""
|
||||||
_write_entry({
|
_write_entry({
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"action": "file_save",
|
"action": "file_save",
|
||||||
"username": username,
|
"username": username,
|
||||||
"ip": ip or "unknown",
|
"ip": ip or "unknown",
|
||||||
@@ -72,7 +72,7 @@ def log_file_delete(
|
|||||||
):
|
):
|
||||||
"""Log a file delete operation."""
|
"""Log a file delete operation."""
|
||||||
_write_entry({
|
_write_entry({
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"action": "file_delete",
|
"action": "file_delete",
|
||||||
"username": username,
|
"username": username,
|
||||||
"ip": ip or "unknown",
|
"ip": ip or "unknown",
|
||||||
@@ -88,7 +88,7 @@ def log_config_change(
|
|||||||
):
|
):
|
||||||
"""Log a configuration change."""
|
"""Log a configuration change."""
|
||||||
_write_entry({
|
_write_entry({
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"action": "config_change",
|
"action": "config_change",
|
||||||
"username": username,
|
"username": username,
|
||||||
"ip": ip or "unknown",
|
"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):
|
def log_vault_add(username: str, vault_name: str, vault_path: str, ip: str | None = None):
|
||||||
"""Log a vault addition."""
|
"""Log a vault addition."""
|
||||||
_write_entry({
|
_write_entry({
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"action": "vault_add",
|
"action": "vault_add",
|
||||||
"username": username,
|
"username": username,
|
||||||
"ip": ip or "unknown",
|
"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):
|
def log_vault_remove(username: str, vault_name: str, ip: str | None = None):
|
||||||
"""Log a vault removal."""
|
"""Log a vault removal."""
|
||||||
_write_entry({
|
_write_entry({
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"action": "vault_remove",
|
"action": "vault_remove",
|
||||||
"username": username,
|
"username": username,
|
||||||
"ip": ip or "unknown",
|
"ip": ip or "unknown",
|
||||||
@@ -136,7 +136,7 @@ def log_coffre_event(
|
|||||||
(coffre:<slug>/<nom>), des identifiants et des codes de résultat.
|
(coffre:<slug>/<nom>), des identifiants et des codes de résultat.
|
||||||
"""
|
"""
|
||||||
entry = {
|
entry = {
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"action": action,
|
"action": action,
|
||||||
"username": username,
|
"username": username,
|
||||||
"ip": ip or "unknown",
|
"ip": ip or "unknown",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import binascii
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from datetime import UTC
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||||
from pydantic import BaseModel, validator
|
from pydantic import BaseModel, validator
|
||||||
@@ -696,7 +697,7 @@ async def mfa_webauthn_register(
|
|||||||
|
|
||||||
Returns recovery codes when MFA is newly enabled (they were never issued).
|
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 .user_store import get_user, update_user
|
||||||
from .webauthn_mfa import complete_registration, resolve_relying_party
|
from .webauthn_mfa import complete_registration, resolve_relying_party
|
||||||
@@ -716,7 +717,7 @@ async def mfa_webauthn_register(
|
|||||||
logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
|
logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
|
||||||
raise HTTPException(400, "Validation du credential WebAuthn échoué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 = list(user.get("webauthn_credentials", []))
|
||||||
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
|
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
|
||||||
creds.append(record)
|
creds.append(record)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import logging
|
|||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .password import hash_password
|
from .password import hash_password
|
||||||
@@ -97,8 +97,8 @@ def create_user(
|
|||||||
"active": True,
|
"active": True,
|
||||||
"language": "fr", # default UI language
|
"language": "fr", # default UI language
|
||||||
"avatar": None, # profile picture data-URL (#113)
|
"avatar": None, # profile picture data-URL (#113)
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
"password_changed_at": datetime.now(timezone.utc).timestamp(),
|
"password_changed_at": datetime.now(UTC).timestamp(),
|
||||||
"last_login": None,
|
"last_login": None,
|
||||||
"failed_attempts": 0,
|
"failed_attempts": 0,
|
||||||
"locked_until": None,
|
"locked_until": None,
|
||||||
@@ -126,7 +126,7 @@ def update_user(username: str, updates: dict) -> dict:
|
|||||||
if "password" in safe_updates:
|
if "password" in safe_updates:
|
||||||
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
|
safe_updates["password_hash"] = hash_password(safe_updates.pop("password"))
|
||||||
# BUG-028: invalidate every token issued before this change.
|
# 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)
|
data["users"][username].update(safe_updates)
|
||||||
_write(data)
|
_write(data)
|
||||||
@@ -148,7 +148,7 @@ def delete_user(username: str):
|
|||||||
def record_login_success(username: str):
|
def record_login_success(username: str):
|
||||||
"""Record a successful login: update last_login, reset failed attempts."""
|
"""Record a successful login: update last_login, reset failed attempts."""
|
||||||
update_user(username, {
|
update_user(username, {
|
||||||
"last_login": datetime.now(timezone.utc).isoformat(),
|
"last_login": datetime.now(UTC).isoformat(),
|
||||||
"failed_attempts": 0,
|
"failed_attempts": 0,
|
||||||
"locked_until": None,
|
"locked_until": None,
|
||||||
})
|
})
|
||||||
@@ -171,7 +171,7 @@ def record_login_failure(username: str) -> int:
|
|||||||
# Lock after 5 failed attempts (15 minutes)
|
# Lock after 5 failed attempts (15 minutes)
|
||||||
if attempts >= 5:
|
if attempts >= 5:
|
||||||
locked_until = (
|
locked_until = (
|
||||||
datetime.now(timezone.utc) + timedelta(minutes=15)
|
datetime.now(UTC) + timedelta(minutes=15)
|
||||||
).isoformat()
|
).isoformat()
|
||||||
updates["locked_until"] = locked_until
|
updates["locked_until"] = locked_until
|
||||||
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
|
logger.warning(f"Account '{username}' locked after {attempts} failed attempts")
|
||||||
@@ -189,7 +189,7 @@ def is_locked(username: str) -> bool:
|
|||||||
if not user or not user.get("locked_until"):
|
if not user or not user.get("locked_until"):
|
||||||
return False
|
return False
|
||||||
locked_until = datetime.fromisoformat(user["locked_until"])
|
locked_until = datetime.fromisoformat(user["locked_until"])
|
||||||
if datetime.now(timezone.utc) > locked_until:
|
if datetime.now(UTC) > locked_until:
|
||||||
# Lock expired — unlock
|
# Lock expired — unlock
|
||||||
update_user(username, {"locked_until": None, "failed_attempts": 0})
|
update_user(username, {"locked_until": None, "failed_attempts": 0})
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -591,7 +591,7 @@ async def api_bookslm_agent(
|
|||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
kind, item = await asyncio.wait_for(queue.get(), timeout=0.25)
|
kind, item = await asyncio.wait_for(queue.get(), timeout=0.25)
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
if run_task.done():
|
if run_task.done():
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -24,14 +24,19 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.coffre_crypto")
|
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)
|
try: # cryptography est une dépendance de la Phase 1a (requirements.txt)
|
||||||
from cryptography.exceptions import InvalidTag
|
from cryptography.exceptions import InvalidTag
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
except Exception: # pragma: no cover - environnement sans cryptography
|
except Exception: # pragma: no cover - environnement sans cryptography
|
||||||
AESGCM = None # type: ignore[assignment]
|
AESGCM = None
|
||||||
InvalidTag = Exception # type: ignore[assignment,misc]
|
InvalidTag = Exception # type: ignore[assignment,misc]
|
||||||
|
|
||||||
|
|
||||||
@@ -55,11 +60,17 @@ class CryptoIndisponibleError(CoffreCryptoError):
|
|||||||
"""La bibliothèque ``cryptography`` n'est pas installée."""
|
"""La bibliothèque ``cryptography`` n'est pas installée."""
|
||||||
|
|
||||||
|
|
||||||
def _require_aesgcm() -> None:
|
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:
|
if AESGCM is None:
|
||||||
raise CryptoIndisponibleError(
|
raise CryptoIndisponibleError(
|
||||||
"cryptography n'est pas installé (dépendance Phase 1a)"
|
"cryptography n'est pas installé (dépendance Phase 1a)"
|
||||||
)
|
)
|
||||||
|
return AESGCM
|
||||||
|
|
||||||
|
|
||||||
def parse_kek(raw: str) -> bytes:
|
def parse_kek(raw: str) -> bytes:
|
||||||
@@ -146,17 +157,17 @@ def _unb64(value: str) -> bytes:
|
|||||||
|
|
||||||
def encrypt_with_key(key: bytes, plaintext: bytes, aad: bytes) -> dict:
|
def encrypt_with_key(key: bytes, plaintext: bytes, aad: bytes) -> dict:
|
||||||
"""Chiffre avec AES-256-GCM. Retourne {nonce, ciphertext} en base64."""
|
"""Chiffre avec AES-256-GCM. Retourne {nonce, ciphertext} en base64."""
|
||||||
_require_aesgcm()
|
aesgcm = _require_aesgcm()
|
||||||
nonce = os.urandom(12)
|
nonce = os.urandom(12)
|
||||||
ciphertext = AESGCM(key).encrypt(nonce, plaintext, aad)
|
ciphertext = aesgcm(key).encrypt(nonce, plaintext, aad)
|
||||||
return {"nonce": _b64(nonce), "ciphertext": _b64(ciphertext)}
|
return {"nonce": _b64(nonce), "ciphertext": _b64(ciphertext)}
|
||||||
|
|
||||||
|
|
||||||
def decrypt_with_key(key: bytes, nonce_b64: str, ciphertext_b64: str, aad: bytes) -> bytes:
|
def decrypt_with_key(key: bytes, nonce_b64: str, ciphertext_b64: str, aad: bytes) -> bytes:
|
||||||
"""Déchiffre avec AES-256-GCM. Tag invalide -> TagInvalideError."""
|
"""Déchiffre avec AES-256-GCM. Tag invalide -> TagInvalideError."""
|
||||||
_require_aesgcm()
|
aesgcm = _require_aesgcm()
|
||||||
try:
|
try:
|
||||||
return AESGCM(key).decrypt(_unb64(nonce_b64), _unb64(ciphertext_b64), aad)
|
return aesgcm(key).decrypt(_unb64(nonce_b64), _unb64(ciphertext_b64), aad)
|
||||||
except InvalidTag as exc:
|
except InvalidTag as exc:
|
||||||
raise TagInvalideError("Tag GCM invalide : données altérées ou mauvaise clé") from exc
|
raise TagInvalideError("Tag GCM invalide : données altérées ou mauvaise clé") from exc
|
||||||
except (binascii.Error, ValueError) as exc:
|
except (binascii.Error, ValueError) as exc:
|
||||||
@@ -177,7 +188,7 @@ def unwrap_dek(wrapped: dict, kek: bytes, coffre_id: str) -> bytes:
|
|||||||
|
|
||||||
def encrypt_secret_value(dek: bytes, secret_id: str, version: int, valeur: str) -> dict:
|
def encrypt_secret_value(dek: bytes, secret_id: str, version: int, valeur: str) -> dict:
|
||||||
"""Chiffre une valeur de secret (AAD = secret_id:version)."""
|
"""Chiffre une valeur de secret (AAD = secret_id:version)."""
|
||||||
aad = f"{secret_id}:{version}".encode("utf-8")
|
aad = f"{secret_id}:{version}".encode()
|
||||||
return encrypt_with_key(dek, valeur.encode("utf-8"), aad)
|
return encrypt_with_key(dek, valeur.encode("utf-8"), aad)
|
||||||
|
|
||||||
|
|
||||||
@@ -185,7 +196,7 @@ def decrypt_secret_value(
|
|||||||
dek: bytes, secret_id: str, version: int, nonce_b64: str, ciphertext_b64: str
|
dek: bytes, secret_id: str, version: int, nonce_b64: str, ciphertext_b64: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Déchiffre une valeur de secret."""
|
"""Déchiffre une valeur de secret."""
|
||||||
aad = f"{secret_id}:{version}".encode("utf-8")
|
aad = f"{secret_id}:{version}".encode()
|
||||||
return decrypt_with_key(dek, nonce_b64, ciphertext_b64, aad).decode("utf-8")
|
return decrypt_with_key(dek, nonce_b64, ciphertext_b64, aad).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -40,19 +40,19 @@ import re
|
|||||||
import secrets as _secrets
|
import secrets as _secrets
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from backend.coffre_crypto import (
|
from backend.coffre_crypto import (
|
||||||
KEKAbsenteError,
|
decrypt_secret_value,
|
||||||
|
encrypt_secret_value,
|
||||||
generate_dek,
|
generate_dek,
|
||||||
load_kek,
|
load_kek,
|
||||||
parse_kek,
|
parse_kek,
|
||||||
reencrypt_secret_value,
|
reencrypt_secret_value,
|
||||||
unwrap_dek,
|
unwrap_dek,
|
||||||
wrap_dek,
|
wrap_dek,
|
||||||
encrypt_secret_value,
|
|
||||||
decrypt_secret_value,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.coffre_store")
|
logger = logging.getLogger("obsigate.coffre_store")
|
||||||
@@ -123,7 +123,7 @@ def _valeurs_path(base: Path) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(value: str | None) -> datetime | None:
|
def _parse_date(value: str | None) -> datetime | None:
|
||||||
@@ -140,7 +140,7 @@ def _parse_date(value: str | None) -> datetime | None:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValidationError(f"Date invalide : {value!r}") from exc
|
raise ValidationError(f"Date invalide : {value!r}") from exc
|
||||||
if parsed.tzinfo is None:
|
if parsed.tzinfo is None:
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
parsed = parsed.replace(tzinfo=UTC)
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ def est_expire(expiration: str | None, maintenant: datetime | None = None) -> bo
|
|||||||
parsed = _parse_date(expiration)
|
parsed = _parse_date(expiration)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return False
|
return False
|
||||||
ref = maintenant or datetime.now(timezone.utc)
|
ref = maintenant or datetime.now(UTC)
|
||||||
return parsed <= ref
|
return parsed <= ref
|
||||||
|
|
||||||
|
|
||||||
@@ -307,7 +307,7 @@ def create_coffre(
|
|||||||
data = _load_coffres(base)
|
data = _load_coffres(base)
|
||||||
if any(c["slug"] == slug for c in data["coffres"]):
|
if any(c["slug"] == slug for c in data["coffres"]):
|
||||||
raise CoffreExisteError(f"Coffre déjà existant : {slug}")
|
raise CoffreExisteError(f"Coffre déjà existant : {slug}")
|
||||||
coffre = {
|
coffre: dict[str, Any] = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"slug": slug,
|
"slug": slug,
|
||||||
"nom": nom,
|
"nom": nom,
|
||||||
@@ -766,7 +766,7 @@ def create_octroi(
|
|||||||
):
|
):
|
||||||
raise IntrouvableError("Secret introuvable dans ce coffre")
|
raise IntrouvableError("Secret introuvable dans ce coffre")
|
||||||
if expiration is None:
|
if expiration is None:
|
||||||
expiration = (datetime.now(timezone.utc) + timedelta(days=DUREE_OCTROI_DEFAUT_JOURS)).isoformat()
|
expiration = (datetime.now(UTC) + timedelta(days=DUREE_OCTROI_DEFAUT_JOURS)).isoformat()
|
||||||
else:
|
else:
|
||||||
_parse_date(expiration)
|
_parse_date(expiration)
|
||||||
with _lock:
|
with _lock:
|
||||||
|
|||||||
+2
-2
@@ -359,7 +359,7 @@ def export_epub(vault_path: Path, file_path: Path) -> bytes:
|
|||||||
|
|
||||||
rendered = _render_body(body, path.parent, vault_path, path)
|
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"?>
|
xhtml = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
|
<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:title>{html_mod.escape(title)}</dc:title>
|
||||||
<dc:creator>{html_mod.escape(author)}</dc:creator>
|
<dc:creator>{html_mod.escape(author)}</dc:creator>
|
||||||
<dc:language>fr</dc:language>
|
<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>
|
</metadata>
|
||||||
<manifest>
|
<manifest>
|
||||||
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ def _export_footer(lang: str) -> str:
|
|||||||
template = loc.get("guide105.export_footer", "")
|
template = loc.get("guide105.export_footer", "")
|
||||||
if "%s" not in template and "{" not in template:
|
if "%s" not in template and "{" not in template:
|
||||||
template = "ObsiGate {version}"
|
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)
|
return _collapse(template).format(version=_app_version(), date=today)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -5,7 +5,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -581,7 +581,7 @@ def _scan_vault(
|
|||||||
try:
|
try:
|
||||||
relative = fpath.relative_to(vault_root)
|
relative = fpath.relative_to(vault_root)
|
||||||
stat = fpath.stat()
|
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
|
# #86 differential scan: reuse the previous entry when neither
|
||||||
# size nor mtime changed — skips the disk read + parse below.
|
# size nor mtime changed — skips the disk read + parse below.
|
||||||
@@ -866,7 +866,7 @@ async def build_index(progress_callback=None) -> None:
|
|||||||
|
|
||||||
# Record timestamp of full index rebuild
|
# Record timestamp of full index rebuild
|
||||||
global _last_full_index_ts
|
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
|
# Build attachment index
|
||||||
from backend.attachment_indexer import build_attachment_index
|
from backend.attachment_indexer import build_attachment_index
|
||||||
@@ -1033,7 +1033,7 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
stat = fpath.stat()
|
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)
|
# PDF handling — binary, must go through pdf_reader (same as _scan_vault)
|
||||||
tags: list[str] = []
|
tags: list[str] = []
|
||||||
|
|||||||
+4
-4
@@ -25,7 +25,7 @@ import os
|
|||||||
import smtplib
|
import smtplib
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -182,7 +182,7 @@ def create_channel(name: str, channel_type: str, config: dict[str, Any]) -> dict
|
|||||||
"type": channel_type,
|
"type": channel_type,
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"config": normalized,
|
"config": normalized,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
"last_sent_at": None,
|
"last_sent_at": None,
|
||||||
"last_error": None,
|
"last_error": None,
|
||||||
}
|
}
|
||||||
@@ -287,7 +287,7 @@ def _send_webhook(url: str, title: str, message: str, trigger: str) -> None:
|
|||||||
"event": trigger,
|
"event": trigger,
|
||||||
"title": title,
|
"title": title,
|
||||||
"message": message,
|
"message": message,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"source": "obsigate-notify",
|
"source": "obsigate-notify",
|
||||||
},
|
},
|
||||||
timeout=10.0,
|
timeout=10.0,
|
||||||
@@ -345,6 +345,6 @@ def _mark_sent(channel_id: str, error: str | None) -> None:
|
|||||||
channels = _read_channels()
|
channels = _read_channels()
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
if channel.get("id") == channel_id:
|
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
|
channel["last_error"] = error
|
||||||
_write_channels(channels)
|
_write_channels(channels)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ TAGS_METADATA: list[dict[str, str]] = [
|
|||||||
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
|
{"name": "Webhooks", "description": "HTTP callbacks signed with HMAC-SHA256 for file events."},
|
||||||
{"name": "Conflicts", "description": "Detect and resolve Syncthing sync-conflict files."},
|
{"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": "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": "Plugins", "description": "Install, enable and manage user plugins."},
|
||||||
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
|
{"name": "Push", "description": "Web Push (VAPID) subscription management and test notifications."},
|
||||||
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
|
{"name": "Duplicates", "description": "Duplicate-note detection and confirmed merge (#166)."},
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ async def subscribe_push(
|
|||||||
"keys": request.subscription.keys,
|
"keys": request.subscription.keys,
|
||||||
"vault": request.vault,
|
"vault": request.vault,
|
||||||
"username": username,
|
"username": username,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat()
|
"created_at": datetime.now(UTC).isoformat()
|
||||||
}
|
}
|
||||||
|
|
||||||
_push_subscriptions.append(subscription)
|
_push_subscriptions.append(subscription)
|
||||||
|
|||||||
@@ -75,3 +75,7 @@ filelock>=4.2.1
|
|||||||
opentelemetry-api>=1.45.1
|
opentelemetry-api>=1.45.1
|
||||||
# Coffre Secrets (Phase 1a) : chiffrement par enveloppe AES-256-GCM.
|
# Coffre Secrets (Phase 1a) : chiffrement par enveloppe AES-256-GCM.
|
||||||
cryptography>=44.0
|
cryptography>=44.0
|
||||||
|
|
||||||
|
# Plancher de securite : msgpack est transitif, version 1.2.1 relevee par le job
|
||||||
|
# security (pip-audit, bloquant) — CVE-2026-114613, corrigee en 1.2.3.
|
||||||
|
msgpack>=1.2.3
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Adaptations strictement équivalentes :
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ async def api_backups_list(
|
|||||||
"file": rel_file,
|
"file": rel_file,
|
||||||
"backup_file": fpath.name,
|
"backup_file": fpath.name,
|
||||||
"timestamp": ts,
|
"timestamp": ts,
|
||||||
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
|
"datetime": datetime.fromtimestamp(ts, tz=UTC).isoformat(),
|
||||||
"size": fsize,
|
"size": fsize,
|
||||||
"full_path": str(fpath),
|
"full_path": str(fpath),
|
||||||
})
|
})
|
||||||
|
|||||||
+127
-2
@@ -19,6 +19,14 @@ Règles d'or, appliquées côté serveur à chaque requête :
|
|||||||
utilisé ici, volontairement).
|
utilisé ici, volontairement).
|
||||||
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
|
- Feature flag ``OBSIGATE_SECRETS_ENABLED`` (défaut désactivé) : tant
|
||||||
qu'il n'est pas activé, toutes les routes répondent 503.
|
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
|
from __future__ import annotations
|
||||||
@@ -35,6 +43,13 @@ from backend.coffre_crypto import (
|
|||||||
KEKAbsenteError,
|
KEKAbsenteError,
|
||||||
generate_kek_b64,
|
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 (
|
from backend.coffre_store import (
|
||||||
CoffreExisteError,
|
CoffreExisteError,
|
||||||
IntrouvableError,
|
IntrouvableError,
|
||||||
@@ -273,6 +288,43 @@ async def api_create_consommateur(
|
|||||||
return {**consommateur, "jeton": jeton}
|
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}")
|
@router.delete("/api/coffres/consommateurs/{consommateur_id}")
|
||||||
async def api_revoke_consommateur(
|
async def api_revoke_consommateur(
|
||||||
consommateur_id: str, current_user=Depends(require_admin)
|
consommateur_id: str, current_user=Depends(require_admin)
|
||||||
@@ -291,6 +343,67 @@ async def api_revoke_consommateur(
|
|||||||
return consommateur
|
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)
|
# Distribution (serveur-à-serveur, hors session utilisateur, hors LLM)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -299,8 +412,20 @@ async def api_revoke_consommateur(
|
|||||||
async def api_distribuer(request: Request, body: dict = Body(...)):
|
async def api_distribuer(request: Request, body: dict = Body(...)):
|
||||||
_exiger_actives()
|
_exiger_actives()
|
||||||
reference = (body.get("reference") or "").strip()
|
reference = (body.get("reference") or "").strip()
|
||||||
contexte_type = (body.get("contexte_type") or "").strip()
|
# Phase 3 (Spark) : le contexte peut arriver à plat
|
||||||
contexte_id = (body.get("contexte_id") or "").strip()
|
# (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:
|
if not contexte_type or not contexte_id:
|
||||||
raise HTTPException(status_code=400, detail="contexte_manquant")
|
raise HTTPException(status_code=400, detail="contexte_manquant")
|
||||||
if not reference.startswith("coffre:") or "/" not in reference[7:]:
|
if not reference.startswith("coffre:") or "/" not in reference[7:]:
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async def api_events(current_user=Depends(require_auth)):
|
|||||||
try:
|
try:
|
||||||
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
|
msg = await asyncio.wait_for(queue.get(), timeout=30.0)
|
||||||
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
|
yield f"event: {msg['event']}\ndata: {msg['data']}\n\n"
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
# Keepalive comment
|
# Keepalive comment
|
||||||
yield ": keepalive\n\n"
|
yield ": keepalive\n\n"
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|||||||
+12
-12
@@ -26,7 +26,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ def _validate_schedule(schedule: dict[str, Any]) -> dict[str, Any]:
|
|||||||
try:
|
try:
|
||||||
parsed = datetime.fromisoformat(at)
|
parsed = datetime.fromisoformat(at)
|
||||||
if parsed.tzinfo is None:
|
if parsed.tzinfo is None:
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
parsed = parsed.replace(tzinfo=UTC)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None
|
raise ValueError("at doit être une date ISO-8601 (ex. 2026-10-05T08:30:00)") from None
|
||||||
return {"kind": kind, "at": parsed.isoformat()}
|
return {"kind": kind, "at": parsed.isoformat()}
|
||||||
@@ -138,7 +138,7 @@ def create_task(
|
|||||||
"""Create a scheduled task. Raises ValueError on invalid action/schedule."""
|
"""Create a scheduled task. Raises ValueError on invalid action/schedule."""
|
||||||
validated_action = _validate_action(action)
|
validated_action = _validate_action(action)
|
||||||
validated_schedule = _validate_schedule(schedule)
|
validated_schedule = _validate_schedule(schedule)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(UTC)
|
||||||
with _lock:
|
with _lock:
|
||||||
tasks = _read_tasks()
|
tasks = _read_tasks()
|
||||||
task = {
|
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:
|
def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datetime:
|
||||||
"""Compute the next due datetime for *task*."""
|
"""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:
|
if now.tzinfo is None:
|
||||||
now = now.replace(tzinfo=timezone.utc)
|
now = now.replace(tzinfo=UTC)
|
||||||
schedule = task.get("schedule", {})
|
schedule = task.get("schedule", {})
|
||||||
kind = schedule.get("kind")
|
kind = schedule.get("kind")
|
||||||
last_run_at = task.get("last_run_at")
|
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:
|
try:
|
||||||
last = datetime.fromisoformat(str(last_run_at))
|
last = datetime.fromisoformat(str(last_run_at))
|
||||||
if last.tzinfo is None:
|
if last.tzinfo is None:
|
||||||
last = last.replace(tzinfo=timezone.utc)
|
last = last.replace(tzinfo=UTC)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
last = None
|
last = None
|
||||||
if kind == "interval_hours":
|
if kind == "interval_hours":
|
||||||
@@ -233,11 +233,11 @@ def compute_next_run(task: dict[str, Any], now: datetime | None = None) -> datet
|
|||||||
try:
|
try:
|
||||||
at = datetime.fromisoformat(str(schedule.get("at")))
|
at = datetime.fromisoformat(str(schedule.get("at")))
|
||||||
if at.tzinfo is None:
|
if at.tzinfo is None:
|
||||||
at = at.replace(tzinfo=timezone.utc)
|
at = at.replace(tzinfo=UTC)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return now
|
return now
|
||||||
if task.get("last_run_at"):
|
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 at
|
||||||
return now + timedelta(hours=24)
|
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"}
|
return {"task_id": task_id, "skipped": True, "reason": "disabled"}
|
||||||
try:
|
try:
|
||||||
result = _execute_action(task)
|
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_status"] = "ok"
|
||||||
task["last_error"] = None
|
task["last_error"] = None
|
||||||
task["run_count"] = int(task.get("run_count", 0)) + 1
|
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.")
|
broadcast("schedule_success", f"Tâche « {task['name']} » OK", "Exécution manuelle réussie.")
|
||||||
return {"task_id": task_id, "ok": True, "result": result}
|
return {"task_id": task_id, "ok": True, "result": result}
|
||||||
except Exception as e:
|
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_status"] = "error"
|
||||||
task["last_error"] = str(e)
|
task["last_error"] = str(e)
|
||||||
task["run_count"] = int(task.get("run_count", 0)) + 1
|
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]]:
|
def tick(now: datetime | None = None) -> list[dict[str, Any]]:
|
||||||
"""Run every due task. Returns per-task outcomes (empty when idle)."""
|
"""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]] = []
|
outcomes: list[dict[str, Any]] = []
|
||||||
for task in _read_tasks():
|
for task in _read_tasks():
|
||||||
if not task.get("enabled", True):
|
if not task.get("enabled", True):
|
||||||
@@ -323,7 +323,7 @@ def tick(now: datetime | None = None) -> list[dict[str, Any]]:
|
|||||||
try:
|
try:
|
||||||
next_run = datetime.fromisoformat(str(task.get("next_run_at") or ""))
|
next_run = datetime.fromisoformat(str(task.get("next_run_at") or ""))
|
||||||
if next_run.tzinfo is None:
|
if next_run.tzinfo is None:
|
||||||
next_run = next_run.replace(tzinfo=timezone.utc)
|
next_run = next_run.replace(tzinfo=UTC)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
next_run = compute_next_run(task, now)
|
next_run = compute_next_run(task, now)
|
||||||
if next_run <= now:
|
if next_run <= now:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
dt = datetime.fromtimestamp(ts, tz=UTC).isoformat()
|
||||||
except (OSError, OverflowError, ValueError) as ts_err:
|
except (OSError, OverflowError, ValueError) as ts_err:
|
||||||
logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}")
|
logger.warning(f"Skipping backup with invalid timestamp {ts}: {ts_err}")
|
||||||
continue
|
continue
|
||||||
@@ -219,7 +219,7 @@ def diff_backup(
|
|||||||
status=500,
|
status=500,
|
||||||
details={"vault": vault_name, "path": path, "version": ts},
|
details={"vault": vault_name, "path": path, "version": ts},
|
||||||
) from e
|
) 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}"
|
return content, f"{path}@{dt}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ def list_recent(
|
|||||||
for v_name, f in recent:
|
for v_name, f in recent:
|
||||||
iso_modified = f.get("modified", "")
|
iso_modified = f.get("modified", "")
|
||||||
try:
|
try:
|
||||||
mtime_dt = datetime.fromisoformat(iso_modified.replace("Z", "+00:00"))
|
mtime_dt = datetime.fromisoformat(iso_modified)
|
||||||
mtime_val = mtime_dt.timestamp()
|
mtime_val = mtime_dt.timestamp()
|
||||||
except Exception:
|
except Exception:
|
||||||
mtime_val = time.time()
|
mtime_val = time.time()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Single source of truth consumed by both the REST routes (``/api/vaults``,
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ def get_vault_root(vault_name: str) -> Path:
|
|||||||
|
|
||||||
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||||
"""Return the direct children of a vault directory (directories first)."""
|
"""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.indexer import SUPPORTED_EXTENSIONS
|
||||||
from backend.vault_settings import get_vault_setting
|
from backend.vault_settings import get_vault_setting
|
||||||
@@ -98,7 +99,7 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
|||||||
"type": "file",
|
"type": "file",
|
||||||
"size": stat.st_size,
|
"size": stat.st_size,
|
||||||
"modified": stat.st_mtime,
|
"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(),
|
"extension": entry.suffix.lower(),
|
||||||
})
|
})
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@@ -156,7 +157,7 @@ def list_all_files(
|
|||||||
ServiceError: ``not_found`` (404) if the directory does not exist,
|
ServiceError: ``not_found`` (404) if the directory does not exist,
|
||||||
``permission_denied`` (403) if it cannot be read.
|
``permission_denied`` (403) if it cannot be read.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
|
|
||||||
from backend.indexer import IGNORED_DIRS
|
from backend.indexer import IGNORED_DIRS
|
||||||
|
|
||||||
@@ -215,7 +216,7 @@ def list_all_files(
|
|||||||
"vault": vault_name,
|
"vault": vault_name,
|
||||||
"size": stat.st_size,
|
"size": stat.st_size,
|
||||||
"modified": stat.st_mtime,
|
"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 "",
|
"extension": ext.lstrip(".") if ext else "",
|
||||||
"tags": _indexed_tags(vault_name, rel_path),
|
"tags": _indexed_tags(vault_name, rel_path),
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -11,7 +11,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.share")
|
logger = logging.getLogger("obsigate.share")
|
||||||
@@ -58,7 +58,7 @@ def create_share(
|
|||||||
|
|
||||||
expires_at = None
|
expires_at = None
|
||||||
if expires_in_hours:
|
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 = {
|
share = {
|
||||||
"id": token,
|
"id": token,
|
||||||
@@ -66,7 +66,7 @@ def create_share(
|
|||||||
"vault": vault,
|
"vault": vault,
|
||||||
"path": path,
|
"path": path,
|
||||||
"created_by": created_by,
|
"created_by": created_by,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
"expires_at": expires_at,
|
"expires_at": expires_at,
|
||||||
"access_count": 0,
|
"access_count": 0,
|
||||||
"last_accessed": None,
|
"last_accessed": None,
|
||||||
@@ -86,7 +86,7 @@ def get_share_by_token(token: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
if share.get("expires_at"):
|
if share.get("expires_at"):
|
||||||
expires = datetime.fromisoformat(share["expires_at"])
|
expires = datetime.fromisoformat(share["expires_at"])
|
||||||
if datetime.now(timezone.utc) > expires:
|
if datetime.now(UTC) > expires:
|
||||||
return None
|
return None
|
||||||
return share
|
return share
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ def record_access(token: str):
|
|||||||
share = data["shares"].get(token)
|
share = data["shares"].get(token)
|
||||||
if share:
|
if share:
|
||||||
share["access_count"] = share.get("access_count", 0) + 1
|
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)
|
_write(data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Reuses the application audit log (``data/audit.log``, JSON lines) and adds an
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from backend.audit import _write_entry
|
from backend.audit import _write_entry
|
||||||
@@ -41,7 +41,7 @@ def log_tool_call(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Append an ``ai_tool_call`` entry to the audit log."""
|
"""Append an ``ai_tool_call`` entry to the audit log."""
|
||||||
_write_entry({
|
_write_entry({
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"action": "ai_tool_call",
|
"action": "ai_tool_call",
|
||||||
"username": username,
|
"username": username,
|
||||||
"ip": ip or "unknown",
|
"ip": ip or "unknown",
|
||||||
|
|||||||
+1
-1
@@ -244,7 +244,7 @@ class VaultWatcher:
|
|||||||
key = event.get('dest') or event['src']
|
key = event.get('dest') or event['src']
|
||||||
pending[key] = event
|
pending[key] = event
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
if pending:
|
if pending:
|
||||||
events_to_process = list(pending.values())
|
events_to_process = list(pending.values())
|
||||||
pending.clear()
|
pending.clear()
|
||||||
|
|||||||
+4
-4
@@ -31,7 +31,7 @@ import os
|
|||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ def create_webhook(
|
|||||||
"url": url,
|
"url": url,
|
||||||
"events": [e for e in events if e in VALID_EVENTS],
|
"events": [e for e in events if e in VALID_EVENTS],
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
"last_fired_at": None,
|
"last_fired_at": None,
|
||||||
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
|
# #198 : filtres optionnels (None = pas de filtre, reçoit tout).
|
||||||
"vault": _normalize_vault_filter(vault),
|
"vault": _normalize_vault_filter(vault),
|
||||||
@@ -344,7 +344,7 @@ async def dispatch_webhooks(event_type: str, data: dict):
|
|||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"event": event_type,
|
"event": event_type,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
"data": data,
|
"data": data,
|
||||||
}
|
}
|
||||||
body = json.dumps(payload, default=str)
|
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})")
|
logger.debug(f"Webhook '{wh['name']}' OK ({resp.status})")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Webhook '{wh['name']}' failed ({resp.status})")
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Webhook '{wh['name']}' error: {e}")
|
logger.warning(f"Webhook '{wh['name']}' error: {e}")
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.79.0"
|
version = "2.81.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.79.0"
|
version = "2.81.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.79.0",
|
"version": "2.81.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Coffre Secrets ObsiGate — Contrat de distribution (Phases 3 et 4)
|
||||||
|
|
||||||
|
Référence du contrat pour les clients serveur-à-serveur : Spark
|
||||||
|
(issue #21), TikTak, Flowdeck, Kanbante (issue #22). Ce document ne
|
||||||
|
duplique aucune logique de chiffrement : le chiffrement reste
|
||||||
|
entièrement côté ObsiGate (enveloppe KEK → DEK → secret, AES-256-GCM).
|
||||||
|
|
||||||
|
## 1. Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/coffres/distribuer
|
||||||
|
Authorization: Bearer <jeton-consommateur>
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
Corps (forme à plat, Phase 1b) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reference": "coffre:<slug>/<nom>",
|
||||||
|
"contexte_type": "mission",
|
||||||
|
"contexte_id": "mission-123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Forme imbriquée acceptée depuis la Phase 2 (client Spark) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reference": "coffre:<slug>/<nom>",
|
||||||
|
"contexte": { "type": "mission", "id": "mission-123" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
La forme à plat reste supportée ; si les deux formes sont présentes,
|
||||||
|
la forme à plat est prioritaire. Le contexte est **obligatoire** :
|
||||||
|
sans `contexte_type`/`contexte_id` (ou sans `contexte.type`/`id`),
|
||||||
|
la requête est refusée avant toute authentification utile.
|
||||||
|
|
||||||
|
Réponse en succès (200) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "reference": "coffre:<slug>/<nom>", "valeur": "<valeur>", "version": 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
La valeur n'existe qu'en mémoire chez l'appelant : elle ne doit
|
||||||
|
jamais être écrite dans un fichier, un journal, un prompt, une étape
|
||||||
|
d'activité ou un livrable. Le patron imposé à chaque app est
|
||||||
|
**distribuer → utiliser → oublier** ; à la fin de l'opération
|
||||||
|
(fin de mission, fin de synchronisation), l'environnement qui la
|
||||||
|
contenait est détruit et rien ne persiste.
|
||||||
|
|
||||||
|
Prérequis côté ObsiGate : coffres activés
|
||||||
|
(`OBSIGATE_SECRETS_ENABLED`, sinon 503 `secrets_desactives`) et KEK
|
||||||
|
configurée (sinon 503 `kek_absente`).
|
||||||
|
|
||||||
|
## 2. Authentification : consommateur nommé, jeton propre
|
||||||
|
|
||||||
|
- Un consommateur est créé par un admin :
|
||||||
|
`POST /api/coffres/consommateurs` (`{"nom": "spark"}`), ou en une
|
||||||
|
seule opération avec ses octrois :
|
||||||
|
`POST /api/coffres/consommateurs/avec-octrois`
|
||||||
|
(`{"nom": "tiktak", "octrois": [{"coffre_slug": "...",
|
||||||
|
"secret_nom": "..."}]}` ; `secret_nom` absent = tout le coffre).
|
||||||
|
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la création.
|
||||||
|
Il n'est jamais stocké en clair (seul son hash SHA-256 est persisté)
|
||||||
|
et jamais relisté (`GET /api/coffres/consommateurs` ne renvoie que
|
||||||
|
`id`, `nom`, `jeton_prefixe`, `statut`, `cree_par`, `cree_le`).
|
||||||
|
- L'`id` du consommateur sert de `jti` révocable :
|
||||||
|
`DELETE /api/coffres/consommateurs/{id}` révoque immédiatement
|
||||||
|
toutes ses distributions, sans toucher aux autres consommateurs.
|
||||||
|
- Ce jeton est **distinct** du jeton MCP de notes et de la session
|
||||||
|
SSO : une session utilisateur, même admin, ne distribue jamais
|
||||||
|
(401 `consommateur_invalide`). Il n'existe pas d'endpoint de
|
||||||
|
distribution par session (voir §6).
|
||||||
|
- Ajouter une app = enregistrer un consommateur + créer des octrois,
|
||||||
|
**sans modification du coffre** et sans toucher à ses secrets.
|
||||||
|
|
||||||
|
## 3. Cas Spark (issue #21)
|
||||||
|
|
||||||
|
- Consommateur nommé `spark` (convention, non imposée par le code :
|
||||||
|
l'autorisation porte sur le jeton, pas sur le nom).
|
||||||
|
- Contexte : `contexte_type = "mission"`, `contexte_id` = identifiant
|
||||||
|
de la mission. La forme imbriquée
|
||||||
|
`contexte: {type: "mission", id: <id-mission>}` est acceptée.
|
||||||
|
- Flux côté agent-commun (hors ObsiGate, rappelé ici pour la
|
||||||
|
correspondance des erreurs) : le LLM ne voit que la référence
|
||||||
|
`coffre:<slug>/<nom>` et un accès oui/non, jamais la valeur. Sans
|
||||||
|
octroi, la mission passe en `acces_requis` en nommant l'accès
|
||||||
|
manquant. Avec octroi et mission approuvée (enveloppe budgétaire),
|
||||||
|
le service Spark appelle `/distribuer` et injecte la valeur en
|
||||||
|
variable d'environnement de l'exécuteur, jamais dans le prompt.
|
||||||
|
Fin de mission = environnement détruit.
|
||||||
|
- Révocation dans ObsiGate (consommateur ou octroi) : la mission
|
||||||
|
suivante repasse en `acces_requis`. Limite assumée : une mission
|
||||||
|
déjà démarrée conserve son environnement jusqu'à sa fin.
|
||||||
|
|
||||||
|
## 4. Erreurs (codes `detail`, stables depuis la Phase 1b)
|
||||||
|
|
||||||
|
| HTTP | `detail` | Sens | Correspondance Spark |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 400 | `contexte_manquant` | Contexte absent ou vide. | Erreur d'appel, pas un état d'accès. |
|
||||||
|
| 400 | `reference_invalide` | Référence mal formée ou nom invalide. | Erreur d'appel. |
|
||||||
|
| 401 | `consommateur_invalide` | Jeton inconnu (ni MCP, ni session). | Configurer le jeton consommateur. |
|
||||||
|
| 403 | `consommateur_revoque` | Consommateur révoqué. | `acces_requis` (ré-accorder). |
|
||||||
|
| 403 | `octroi_absent` | Aucun octroi (ou secret/coffre inexistant — anti-énumération, volontairement indistinct). | `acces_requis` (demander l'octroi). |
|
||||||
|
| 403 | `octroi_revoque` | Octroi révoqué. | `acces_requis` (ré-accorder). |
|
||||||
|
| 403 | `octroi_expire` | Octroi expiré (défaut 90 jours à la création). | `acces_requis` (renouveler). |
|
||||||
|
| 410 | `secret_expire` | Secret expiré, même avec un octroi actif. | Rotation du secret côté ObsiGate. |
|
||||||
|
| 503 | `secrets_desactives` / `kek_absente` / `crypto_indisponible` | Coffre désactivé, KEK absente, ou bibliothèque absente. | Panne ObsiGate, pas un refus d'accès. |
|
||||||
|
|
||||||
|
Un octroi coffre-wide (`secret_id` nul) couvre tous les secrets du
|
||||||
|
coffre ; un octroi par secret ne couvre que ce secret. Un octroi
|
||||||
|
révoqué ne doit jamais être masqué par l'absence d'un autre octroi
|
||||||
|
(priorité : ok > révoqué > expiré > absent).
|
||||||
|
|
||||||
|
## 5. Audit
|
||||||
|
|
||||||
|
Chaque distribution est auditée (`distribution_ok` ou
|
||||||
|
`distribution_refusee`, avec référence, consommateur, contexte et
|
||||||
|
code de résultat). **Aucune entrée d'audit ne contient jamais une
|
||||||
|
valeur, un texte chiffré, un nonce ou un jeton.** La route journal
|
||||||
|
(`GET /api/coffres/{slug}/journal`) filtre en défense en profondeur
|
||||||
|
ces champs même s'ils apparaissaient un jour dans une entrée.
|
||||||
|
La copie manuelle à usage unique (admin, via consommateur temporaire
|
||||||
|
créé puis révoqué immédiatement, valeur copiée au presse-papiers
|
||||||
|
sans affichage) est auditée comme une distribution de contexte
|
||||||
|
`copie_manuelle`, avec la trace distincte de ce contexte.
|
||||||
|
|
||||||
|
## 6. Limites et restes à faire (Phases 3–4, côté ObsiGate)
|
||||||
|
|
||||||
|
- **Isolation workspace Flowdeck** : le type de cible `workspace`
|
||||||
|
existe pour les octrois, mais `/distribuer` n'autorise aujourd'hui
|
||||||
|
que les octrois de type `consommateur`. L'isolation « un workspace
|
||||||
|
ne peut pas utiliser l'octroi d'un autre » repose donc, pour
|
||||||
|
l'instant, sur le serveur Flowdeck lui-même (il ne demande que les
|
||||||
|
références de son workspace, avec son propre jeton consommateur).
|
||||||
|
Un contrôle serveur strict (contexte workspace vérifié côté
|
||||||
|
ObsiGate contre un octroi `workspace`) reste à spécifier et à
|
||||||
|
implémenter si Bruno l'exige — il changerait la sémantique
|
||||||
|
d'autorisation de `/distribuer` et doit être coordonné avec les
|
||||||
|
apps clientes avant tout changement.
|
||||||
|
- **Distribution par session** : non implémentée, volontairement.
|
||||||
|
Elle contredirait la règle Phase 1b (distribution réservée aux
|
||||||
|
consommateurs) et exigerait une ré-authentification qui n'existe
|
||||||
|
pas encore dans ObsiGate. La copie manuelle reste le patron
|
||||||
|
supporté (consommateur temporaire, admin seulement).
|
||||||
|
- **Clients par app** (TikTak, Flowdeck, Kanbante, agent-commun) :
|
||||||
|
à implémenter dans chaque dépôt, en branche + PR, jamais mergés
|
||||||
|
sans go. Patron commun : le serveur distribue, utilise, oublie ;
|
||||||
|
aucune valeur en cache ; si ObsiGate est indisponible, l'opération
|
||||||
|
échoue proprement, sans repli sur une ancienne valeur.
|
||||||
|
- **Lecteurs internes ObsiGate** (`ai.py`, `tools/secrets.py`) :
|
||||||
|
toujours branchés sur `api_keys.json` en clair (voir le document
|
||||||
|
de sauvegarde/restauration, §5) ; leur branchement sur le coffre
|
||||||
|
est un travail séparé de la présente Phase 2.
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
# Coffre Secrets ObsiGate — Déploiement et mise en service (Phase 5, issue #23)
|
||||||
|
|
||||||
|
Document de déploiement du coffre Secrets, issu des Phases 1a/1b
|
||||||
|
(issues #17, #18), 2 (issue #20), 3 (issue #21, côté agent-commun)
|
||||||
|
et 4 (issue #22, côté TikTak, Flowdeck, Kanbante).
|
||||||
|
|
||||||
|
**Règles de gouvernance (non négociables) :** travail en branche +
|
||||||
|
pull request, jamais sur `main`, jamais de merge, de release, de tag,
|
||||||
|
de fermeture d'issue ni de changement de label sans le go explicite
|
||||||
|
de Bruno. Ce document ne décrit aucune valeur réelle : les exemples
|
||||||
|
utilisent des valeurs fictives (`TEST-VALUE-XXXX`) et aucune KEK
|
||||||
|
n'est jamais écrite ici, ni dans un journal, ni dans une note, ni
|
||||||
|
dans le dépôt.
|
||||||
|
|
||||||
|
Documents compagnons (même branche de référence, Phase 2) :
|
||||||
|
|
||||||
|
- `docs/COFFRE-contrat-distribution.md` — contrat
|
||||||
|
`POST /api/coffres/distribuer` pour Spark, TikTak, Flowdeck,
|
||||||
|
Kanbante (authentification par consommateur, contextes, codes
|
||||||
|
d'erreur stables).
|
||||||
|
- `docs/COFFRE-sauvegarde-restauration.md` — sauvegarde
|
||||||
|
(`data/coffres.json` + `data/coffres_valeurs.json`, 0600) et
|
||||||
|
restauration sur instance vierge, perte de KEK.
|
||||||
|
|
||||||
|
## 1. Prérequis et variables d'environnement
|
||||||
|
|
||||||
|
### 1.1 Dépendance
|
||||||
|
|
||||||
|
- `cryptography>=44.0` dans `backend/requirements.txt` (dépendance
|
||||||
|
Phase 1a). Sans elle, les opérations de chiffrement répondent
|
||||||
|
503 `crypto_indisponible` : c'est un mode dégradé explicite, pas
|
||||||
|
un repli en clair. Il n'y a jamais de stockage ni de distribution
|
||||||
|
de valeur en clair.
|
||||||
|
- Chiffrement par enveloppe AES-256-GCM : KEK (256 bits) → DEK par
|
||||||
|
coffre → valeur du secret. Un tag GCM invalide refuse le
|
||||||
|
déchiffrement (testé : `tests/test_coffre_secrets.py`,
|
||||||
|
`test_tag_invalide_refuse`).
|
||||||
|
|
||||||
|
### 1.2 Variables
|
||||||
|
|
||||||
|
| Variable | Rôle | Défaut |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Tant qu'elle n'est pas à `true`, **toutes** les routes coffre répondent 503 `secrets_desactives`. | désactivé |
|
||||||
|
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). **Prioritaire** sur le fichier si les deux sont définis. | absente |
|
||||||
|
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier contenant la KEK, **en 0600, hors dépôt et hors arborescence des vaults**. Un fichier trop permissif (droits groupe/autre) est refusé explicitement (`KEKInvalideError`). | absent |
|
||||||
|
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut) : `coffres.json`, `coffres_valeurs.json` et l'historique `api_keys.json`. | `data` |
|
||||||
|
|
||||||
|
La KEK est générée une seule fois (admin, coffres activés) via
|
||||||
|
`POST /api/coffres/admin/generer-kek` ; ObsiGate **ne la stocke
|
||||||
|
jamais**. Au moment de sa génération : la copier immédiatement
|
||||||
|
dans un gestionnaire de mots de passe ou sur un support hors ligne,
|
||||||
|
hors du serveur ; vérifier la copie en la relisant depuis le
|
||||||
|
gestionnaire, jamais depuis l'écran qui ne l'affichera plus.
|
||||||
|
Ne jamais la coller dans le dépôt, dans une note ObsiGate, dans le
|
||||||
|
même dossier que `data/`, ni dans un journal.
|
||||||
|
|
||||||
|
**Recommandation de déploiement : préférer `OBSIGATE_KEK_FILE`**
|
||||||
|
(fichier 0600) à `OBSIGATE_KEK` en variable d'environnement, pour
|
||||||
|
éviter que la KEK apparaisse dans un historique de processus ou un
|
||||||
|
dump d'environnement. Si les deux sont définis, `OBSIGATE_KEK`
|
||||||
|
l'emporte — vérifier qu'aucune ancienne valeur ne traîne dans
|
||||||
|
l'environnement au moment de basculer sur le fichier.
|
||||||
|
|
||||||
|
### 1.3 Ce que la sauvegarde contient (rappel)
|
||||||
|
|
||||||
|
Deux fichiers seulement portent le coffre, tous deux en 0600 :
|
||||||
|
|
||||||
|
- `data/coffres.json` : métadonnées des coffres, DEK chiffrées,
|
||||||
|
consommateurs (hashes SHA-256 des jetons seulement, jamais le
|
||||||
|
jeton en clair), octrois, métadonnées des secrets ;
|
||||||
|
- `data/coffres_valeurs.json` : versions chiffrées des secrets.
|
||||||
|
|
||||||
|
Copiés seuls, ils ne donnent accès à aucune valeur. La KEK ne fait
|
||||||
|
pas partie de la sauvegarde : elle est copiée à part, hors serveur.
|
||||||
|
Sauvegarder les deux fichiers et la KEK au même endroit annulerait
|
||||||
|
la protection du chiffrement.
|
||||||
|
|
||||||
|
## 2. Décisions ouvertes de la spec — tranchées par défaut
|
||||||
|
|
||||||
|
Les décisions ouvertes de la spec (§15, §16), rappelées dans
|
||||||
|
l'issue #23, sont confirmées comme tranchées selon les défauts
|
||||||
|
recommandés, en attendant une décision contraire explicite de
|
||||||
|
Bruno. Aucune d'elles ne nécessite de changement de code pour la
|
||||||
|
mise en service décrite ci-dessous :
|
||||||
|
|
||||||
|
| Décision | Choix confirmé par défaut |
|
||||||
|
| --- | --- |
|
||||||
|
| Glyphe du nœud Secrets | Cadenas U+1F510 (🔐), distinct de la maison du Home et du diamant du Partage. Le nœud est un nœud virtuel sous le Home de l'utilisateur courant seulement (voir §4). |
|
||||||
|
| Stockage de la KEK | Par fichier 0600, hors dépôt (`OBSIGATE_KEK_FILE`) ; `OBSIGATE_KEK` reste supportée comme solution de secours. |
|
||||||
|
| Expiration des octrois | 90 jours par défaut à la création, renouvelable (création d'un nouvel octroi). Un octroi expiré refuse la distribution (`octroi_expire`, 403) ; un octroi révoqué refuse dès l'appel suivant (`octroi_revoque`, 403). |
|
||||||
|
| Copie manuelle | Presse-papiers seul, sans affichage de la valeur. Patron supporté : consommateur temporaire créé par un admin puis révoqué immédiatement, auditée comme distribution de contexte `copie_manuelle`. **Aucun endpoint dédié de copie par session n'est ajouté** : il contredirait la règle « distribution réservée aux consommateurs » et exigerait une ré-authentification qui n'existe pas encore — décision volontaire, à trancher avec Bruno avant toute implémentation. |
|
||||||
|
| Coffres partagés | Phase 2 ultérieure. En mise en service : coffres personnels d'abord, plus le coffre d'instance « ObsiGate interne » (slug `obsigate-interne`, admin seulement) issu de l'import. |
|
||||||
|
|
||||||
|
## 3. Mise en service pas à pas
|
||||||
|
|
||||||
|
**Ordre impératif.** Chaque étape est validée avant de passer à la
|
||||||
|
suivante. En particulier : **les lecteurs internes sont branchés
|
||||||
|
sur le coffre AVANT toute purge du clair** (étape 4 avant étape 7),
|
||||||
|
et la purge n'a lieu qu'après validation complète (étape 6).
|
||||||
|
|
||||||
|
### Étape 0 — Préparer, coffre encore désactivé
|
||||||
|
|
||||||
|
1. Déployer le code coffre (Phases 1–2) sur l'instance, **sans
|
||||||
|
activer le flag** : `OBSIGATE_SECRETS_ENABLED` reste absent ou à
|
||||||
|
`false`. Vérifier que toutes les routes `/api/coffres/*`
|
||||||
|
répondent 503.
|
||||||
|
2. Sauvegarder l'état actuel : copie de `data/` (dont
|
||||||
|
`api_keys.json`) et de la configuration, hors serveur.
|
||||||
|
3. Générer la KEK (voir §1.2) et la copier hors serveur. Ne pas
|
||||||
|
encore la référencer dans la configuration de l'instance si
|
||||||
|
l'étape 1 n'est pas terminée — une KEK présente sans flag actif
|
||||||
|
ne distribue rien, mais sa présence doit rester traçable.
|
||||||
|
|
||||||
|
### Étape 1 — Créer les consommateurs et les octrois
|
||||||
|
|
||||||
|
Avant d'activer le flag, préparer les consommateurs nommés, un par
|
||||||
|
application cliente (convention de nommage, non imposée par le
|
||||||
|
code — l'autorisation porte sur le jeton, pas sur le nom) :
|
||||||
|
|
||||||
|
| Consommateur | Usage | Contexte de distribution |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `spark` | Agent Spark (agent-commun, Phase 3) | `contexte_type = "mission"`, `contexte_id` = id de mission (forme imbriquée `contexte: {type, id}` acceptée) |
|
||||||
|
| `tiktak` | Synchronisation TikTak (Phase 4) | `contexte_type = "tiktak_sync"`, `contexte_id` = id utilisateur |
|
||||||
|
| `flowdeck` | Connecteurs Flowdeck (Phase 4) | Contexte workspace, vérifié côté Flowdeck (voir §5) |
|
||||||
|
| `kanbante` | Webhooks Kanbante (Phase 4) | Contexte webhook, distribution à chaque appel puis oubli |
|
||||||
|
|
||||||
|
Création en une seule opération avec les octrois :
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/coffres/consommateurs/avec-octrois
|
||||||
|
{"nom": "tiktak", "octrois": [{"coffre_slug": "...", "secret_nom": "..."}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `secret_nom` absent = tout le coffre (octroi coffre-wide).
|
||||||
|
- Le jeton (`csk_…`) n'est rendu **qu'une seule fois**, à la
|
||||||
|
création. Le noter immédiatement dans le coffre de
|
||||||
|
configuration de l'application cliente concernée, jamais dans
|
||||||
|
une note ObsiGate ni dans le dépôt. Il n'est jamais relisté :
|
||||||
|
`GET /api/coffres/consommateurs` ne renvoie que `id`, `nom`,
|
||||||
|
`jeton_prefixe`, `statut`, `cree_par`, `cree_le`.
|
||||||
|
- La révocation est immédiate : `DELETE
|
||||||
|
/api/coffres/consommateurs/{id}` révoque toutes les
|
||||||
|
distributions de ce consommateur, sans toucher aux autres.
|
||||||
|
Une mission Spark déjà démarrée conserve son environnement
|
||||||
|
jusqu'à sa fin ; la mission suivante repasse en `acces_requis`.
|
||||||
|
|
||||||
|
À ce stade, les consommateurs et octrois peuvent être créés alors
|
||||||
|
que les secrets n'existent pas encore : les octrois pointent vers
|
||||||
|
des coffres/secrets à créer aux étapes suivantes. Vérifier la
|
||||||
|
liste des consommateurs et des octrois avant d'activer le flag.
|
||||||
|
|
||||||
|
### Étape 2 — Activer le flag
|
||||||
|
|
||||||
|
1. Définir `OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt) puis
|
||||||
|
`OBSIGATE_SECRETS_ENABLED=true`.
|
||||||
|
2. Redémarrer l'instance.
|
||||||
|
3. Vérifier : la vue Secrets s'ouvre ; `GET /api/coffres` répond
|
||||||
|
200 (liste vide ou coffres existants) ; sans KEK valide, les
|
||||||
|
routes répondent 503 `kek_absente` — dans ce cas, corriger la
|
||||||
|
KEK avant toute création de secret.
|
||||||
|
|
||||||
|
### Étape 3 — Importer l'existant (admin seulement)
|
||||||
|
|
||||||
|
Bouton « Importer l'existant » de la vue Secrets (admin
|
||||||
|
seulement), en deux temps séparés :
|
||||||
|
|
||||||
|
1. **Aperçu** : `GET /api/coffres/migration/api-keys` liste les
|
||||||
|
candidats de `data/api_keys.json` (clés IA et clés d'outils).
|
||||||
|
2. **Import** : `POST /api/coffres/migration/api-keys/import`
|
||||||
|
copie les entrées avec valeur vers le coffre d'instance
|
||||||
|
« ObsiGate interne » (slug `obsigate-interne`), chiffrées.
|
||||||
|
Un secret déjà présent n'est **jamais écrasé**.
|
||||||
|
`api_keys.json` n'est **pas modifié** par l'import.
|
||||||
|
|
||||||
|
Vérifier après import : les secrets importés apparaissent dans la
|
||||||
|
vue Secrets avec `has_value = true`, et aucune valeur n'est
|
||||||
|
visible (métadonnées seules : nom, type, version, dates, octrois
|
||||||
|
actifs — jamais de valeur, jamais de texte chiffré, jamais de
|
||||||
|
nonce).
|
||||||
|
|
||||||
|
### Étape 4 — Brancher les lecteurs internes AVANT toute purge
|
||||||
|
|
||||||
|
**Point de vigilance principal de la mise en service.** Les
|
||||||
|
lecteurs actuels d'ObsiGate (`backend/ai.py`,
|
||||||
|
`backend/tools/secrets.py`) lisent encore `data/api_keys.json`
|
||||||
|
directement ; ils ne sont pas encore branchés sur le coffre
|
||||||
|
(reste à faire explicite des Phases 2–5). Tant que ce branchement
|
||||||
|
n'est pas livré et validé, **purger le clair priverait l'IA et les
|
||||||
|
outils de leurs clés**.
|
||||||
|
|
||||||
|
Ordre à respecter :
|
||||||
|
|
||||||
|
1. Ne pas purger.
|
||||||
|
2. Livrer (en branche + PR, jamais mergé sans go) le branchement
|
||||||
|
des lecteurs internes sur une distribution depuis le coffre
|
||||||
|
d'instance, puis le valider sur l'instance : l'IA et les outils
|
||||||
|
fonctionnent avec `api_keys.json` rendu temporairement
|
||||||
|
inaccessible en lecture (test de coupure contrôlée), ou à
|
||||||
|
défaut avec un secret de test distribué depuis le coffre.
|
||||||
|
3. Seulement après cette validation, passer à l'étape 7.
|
||||||
|
|
||||||
|
Si Bruno accepte une interruption temporaire de l'IA/outils, le
|
||||||
|
documenter explicitement avant la purge ; sinon, la purge attend
|
||||||
|
le branchement.
|
||||||
|
|
||||||
|
### Étape 5 — Brancher les applications clientes
|
||||||
|
|
||||||
|
Pour chaque application (branches dédiées, jamais mergées sans
|
||||||
|
go), appliquer le patron commun **distribuer → utiliser →
|
||||||
|
oublier** : le serveur distribue à chaque opération, utilise la
|
||||||
|
valeur en mémoire, puis l'oublie ; aucune valeur en cache, aucune
|
||||||
|
persistance. Si ObsiGate est indisponible, l'opération échoue
|
||||||
|
proprement, sans repli sur une ancienne valeur.
|
||||||
|
|
||||||
|
- **Spark (agent-commun, Phase 3)** : identités référencées
|
||||||
|
`coffre:<slug>/<nom>` (slug 3–64, nom 3–128, minuscules /
|
||||||
|
chiffres / `-` / `_`), sans colonne valeur ; le LLM ne voit que
|
||||||
|
la référence et un accès oui/non. Sans octroi, la mission passe
|
||||||
|
en `acces_requis` en nommant l'accès manquant ; avec octroi et
|
||||||
|
mission approuvée (enveloppe budgétaire), distribution puis
|
||||||
|
injection en variable d'environnement de l'exécuteur, jamais
|
||||||
|
dans le prompt, jamais dans les étapes d'activité. Fin de
|
||||||
|
mission = environnement détruit.
|
||||||
|
- **TikTak (Phase 4)** : référence seule stockée ; distribution
|
||||||
|
serveur à chaque synchro ; masquage côté Android (ni référence
|
||||||
|
complète ni valeur dans les réponses vues par une clé `tck_`).
|
||||||
|
Les configurations existantes sont marquées `a_resaisir = 1`
|
||||||
|
(migration 019 : colonne en clair supprimée, référence vide) :
|
||||||
|
il n'y a **pas de conversion automatique** — chaque
|
||||||
|
configuration doit être re-saisie par référence.
|
||||||
|
- **Flowdeck (Phase 4)** : `workspace_id` + référence sur les
|
||||||
|
connecteurs ; distribution en mémoire ; isolation A vs B
|
||||||
|
testée (un workspace ne distribue pas avec l'octroi d'un autre).
|
||||||
|
Les connecteurs natifs Gitea/GitHub et les clés LLM ne sont pas
|
||||||
|
basculés ; pas de sélecteur UI (API seulement).
|
||||||
|
- **Kanbante (Phase 4)** : secret webhook migré en référence,
|
||||||
|
distribuée à chaque webhook puis oubliée ; client générique
|
||||||
|
réutilisable. Les jetons forge chiffrés Fernet ne sont **pas**
|
||||||
|
migrés (distribution par appel requise, incompatible avec le
|
||||||
|
cache `ForgeRegistry`) ; les secrets webhook historiques en
|
||||||
|
clair restent acceptés par compatibilité, sans migration
|
||||||
|
automatique.
|
||||||
|
|
||||||
|
### Étape 6 — Valider (avant purge)
|
||||||
|
|
||||||
|
Exécuter la checklist de validation de l'instance réelle (§4).
|
||||||
|
Tant qu'un point échoue, ne pas purger et ne pas considérer la
|
||||||
|
mise en service comme terminée.
|
||||||
|
|
||||||
|
### Étape 7 — Purger, seulement après validation
|
||||||
|
|
||||||
|
`POST /api/coffres/migration/api-keys/purger`, **après
|
||||||
|
confirmation explicite** (`confirmer = true`), et uniquement pour
|
||||||
|
les noms migrés. Sans confirmation, rien n'est supprimé — jamais
|
||||||
|
automatiquement.
|
||||||
|
|
||||||
|
Après purge, vérifier :
|
||||||
|
|
||||||
|
- `api_keys.json` ne contient plus les noms purgés ;
|
||||||
|
- l'IA et les outils fonctionnent toujours (lecteurs branchés,
|
||||||
|
étape 4) ;
|
||||||
|
- une distribution de test via un consommateur existant aboutit
|
||||||
|
(succès seul vérifié, valeur ni affichée ni consignée).
|
||||||
|
|
||||||
|
### Étape 8 — Sauvegarder et tester la restauration
|
||||||
|
|
||||||
|
1. Sauvegarder `data/coffres.json` + `data/coffres_valeurs.json`
|
||||||
|
(0600) par la sauvegarde habituelle du serveur, et la KEK à
|
||||||
|
part, hors serveur (§1.3).
|
||||||
|
2. Rejouer la restauration sur une instance vierge (procédure
|
||||||
|
`docs/COFFRE-sauvegarde-restauration.md` §3 : arrêt de la
|
||||||
|
cible, copie des deux fichiers en 0600, même KEK, redémarrage,
|
||||||
|
vérification `has_value = true` + distribution de test +
|
||||||
|
`distribution_ok` dans le journal). La suite automatisée
|
||||||
|
(`tests/test_coffre_secrets.py`, classe
|
||||||
|
`TestRestaurationVierge`) simule cette restauration ; elle ne
|
||||||
|
remplace pas un essai sur instance réelle.
|
||||||
|
3. En cas de perte de KEK : perte d'accès assumée, par
|
||||||
|
construction — aucun moyen de récupérer les valeurs. Recréer
|
||||||
|
les coffres avec une nouvelle KEK, régénérer les valeurs côté
|
||||||
|
fournisseur, révoquer les anciennes côté fournisseur, recréer
|
||||||
|
consommateurs et octrois. Ne jamais tenter de « réparer » les
|
||||||
|
anciens fichiers avec une nouvelle KEK (le déchiffrement
|
||||||
|
échouera sur tag GCM invalide, ce qui est attendu).
|
||||||
|
|
||||||
|
## 4. Checklist de validation par Bruno sur instance réelle
|
||||||
|
|
||||||
|
Validation exigée par l'issue #23 avant tout usage réel. Chaque
|
||||||
|
point est validé sur capture, comme pour les autres livraisons :
|
||||||
|
|
||||||
|
- [ ] **Sidebar** : le nœud « Secrets » (cadenas U+1F510)
|
||||||
|
apparaît sous le Home de l'utilisateur courant seulement, et
|
||||||
|
pas sous les autres vaults ; cliquer dessus ouvre la vue
|
||||||
|
Secrets sans naviguer dans les fichiers.
|
||||||
|
- [ ] **Secret de test** : création d'un secret de test
|
||||||
|
(valeur fictive, ex. `TEST-VALUE-XXXX`) dans un coffre
|
||||||
|
personnel, puis lecture : métadonnées seules, aucune valeur
|
||||||
|
affichée, aucun texte chiffré, aucun nonce.
|
||||||
|
- [ ] **Journal d'audit** : après une distribution de test, le
|
||||||
|
journal contient `distribution_ok` pour la référence, et un
|
||||||
|
`grep` du journal sur la valeur de test ne renvoie **rien**
|
||||||
|
(ni valeur, ni texte chiffré, ni nonce, ni jeton).
|
||||||
|
- [ ] **Autorisations** : un admin ne voit pas le coffre
|
||||||
|
personnel d'autrui ; une ACL `*` sur un vault ne donne aucun
|
||||||
|
droit sur un coffre ; une session utilisateur, même admin, ne
|
||||||
|
distribue pas (401 `consommateur_invalide`).
|
||||||
|
- [ ] **Distribution** : contexte manquant refusé (400
|
||||||
|
`contexte_manquant`) ; octroi révoqué refusé dès l'appel
|
||||||
|
suivant (403 `octroi_revoque`) ; octroi expiré refusé (403
|
||||||
|
`octroi_expire`) ; secret expiré refusé (410 `secret_expire`).
|
||||||
|
- [ ] **Import / purge** : l'import ne modifie pas
|
||||||
|
`api_keys.json` et n'écrase pas un secret existant ; la purge
|
||||||
|
sans confirmation explicite ne supprime rien.
|
||||||
|
- [ ] **Restauration** : sauvegarde + KEK externe restaurées sur
|
||||||
|
une instance vierge, distributions fonctionnelles.
|
||||||
|
- [ ] **Intégrations** : mission Spark en `acces_requis` sans
|
||||||
|
octroi, puis injection exécuteur sans trace dans les étapes ;
|
||||||
|
synchro TikTak sans valeur côté Android ; isolation workspace
|
||||||
|
Flowdeck (A ne distribue pas avec l'octroi de B).
|
||||||
|
- [ ] **Modale** : la modale de création de secret n'est plus
|
||||||
|
coupée en haut (`.obsigate-modal` bornée au viewport, corps
|
||||||
|
seul défilant, en-tête et pied toujours visibles).
|
||||||
|
|
||||||
|
## 5. Limites connues à la mise en service
|
||||||
|
|
||||||
|
À lire avant de déclarer la mise en service terminée :
|
||||||
|
|
||||||
|
- **Lecteurs internes non branchés** (`ai.py`,
|
||||||
|
`tools/secrets.py` lisent encore `api_keys.json` en clair) :
|
||||||
|
purger avant de les brancher priverait l'IA/outils de leurs
|
||||||
|
clés (§3, étape 4).
|
||||||
|
- **Endpoint dédié de copie manuelle par session non ajouté**
|
||||||
|
(décision volontaire, §2) : la copie manuelle passe par un
|
||||||
|
consommateur temporaire admin, presse-papiers seul.
|
||||||
|
- **Isolation workspace Flowdeck non contrôlée côté ObsiGate** :
|
||||||
|
`/distribuer` n'autorise aujourd'hui que les octrois de type
|
||||||
|
`consommateur` ; l'isolation workspace repose sur le serveur
|
||||||
|
Flowdeck lui-même (il ne demande que les références de son
|
||||||
|
workspace, avec son propre jeton). Un contrôle serveur strict
|
||||||
|
reste à spécifier si Bruno l'exige.
|
||||||
|
- **Identités agent-commun** : détenteur libre hors coffre
|
||||||
|
conservé (historique) ; révocation Identités non propagée vers
|
||||||
|
ObsiGate (pas d'endpoint) ; exécuteurs CLI synchrones
|
||||||
|
uniquement.
|
||||||
|
- **TikTak** : « sélecteur » = saisie validée, pas de liste
|
||||||
|
déroulante (aucune API de listage côté ObsiGate) ;
|
||||||
|
configurations existantes à re-saisir, pas de conversion
|
||||||
|
automatique.
|
||||||
|
- **Flowdeck** : natifs Gitea/GitHub et clés LLM non basculés ;
|
||||||
|
pas de sélecteur UI (API seulement) ; `workspace_id` pas encore
|
||||||
|
branché depuis AgentEngine.
|
||||||
|
- **Kanbante** : jetons forge chiffrés Fernet non migrés ;
|
||||||
|
pas de migration automatique des secrets webhook historiques.
|
||||||
|
- **Recherche / graphe / index** : par construction, un coffre
|
||||||
|
n'est pas un vault de notes — il ne passe ni par l'indexeur, ni
|
||||||
|
par la recherche, ni par le graphe, et aucun outil MCP de secret
|
||||||
|
n'existe. Aucune métadonnée de secret ne doit apparaître dans
|
||||||
|
ces index (vérification par revue de code, pas de suite
|
||||||
|
automatisée dédiée — voir le bilan Phase 5).
|
||||||
|
|
||||||
|
## 6. Repli (rollback)
|
||||||
|
|
||||||
|
- Désactiver le flag (`OBSIGATE_SECRETS_ENABLED` absent ou
|
||||||
|
`false`) : toutes les routes coffre répondent 503, les fichiers
|
||||||
|
`coffres.json` / `coffres_valeurs.json` restent intacts et
|
||||||
|
re-distribueront dès réactivation avec la même KEK.
|
||||||
|
- Révoquer un consommateur (`DELETE
|
||||||
|
/api/coffres/consommateurs/{id}`) coupe immédiatement ses
|
||||||
|
distributions, sans toucher aux autres consommateurs ni aux
|
||||||
|
secrets.
|
||||||
|
- Ne jamais « réparer » des fichiers chiffrés avec une nouvelle
|
||||||
|
KEK, ne jamais restaurer `coffres.json` sans
|
||||||
|
`coffres_valeurs.json` (et inversement), ne jamais copier la
|
||||||
|
KEK au même endroit que la sauvegarde.
|
||||||
|
|
||||||
|
## 7. Références
|
||||||
|
|
||||||
|
- Spec « Coffre de secrets ObsiGate » §6 (autorisations), §7/§10/§11
|
||||||
|
(API), §13/§14 (sauvegarde/restauration), §15/§16 (tests,
|
||||||
|
déploiement).
|
||||||
|
- Issues ObsiGate : #17 (Phase 1a), #18 (Phase 1b), #19 (Phase 1c
|
||||||
|
UI), #20 (Phase 2), #21 (Phase 3, Spark), #22 (Phase 4, TikTak /
|
||||||
|
Flowdeck / Kanbante), #23 (Phase 5, ce document).
|
||||||
|
- Suites de tests : `tests/test_coffre_secrets.py` (ObsiGate, 35
|
||||||
|
tests), `tests/test_identites_coffre_phase3.py` (agent-commun,
|
||||||
|
16 tests), `tests/test_coffre_reference.py` (TikTak, 17 fonctions
|
||||||
|
/ 26 cas), `tests/test_coffre_reference_flowdeck.py` (Flowdeck,
|
||||||
|
16 tests), `tests/test_coffre_client.py` (Kanbante, 11 fonctions
|
||||||
|
/ 25 cas). Voir le bilan Phase 5 pour la matrice de couverture
|
||||||
|
complète et les manques.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Coffre Secrets ObsiGate — Sauvegarde et restauration (Phase 2, issue #20)
|
||||||
|
|
||||||
|
Ce document décrit la seule procédure supportée pour sauvegarder et
|
||||||
|
restaurer le coffre Secrets, ainsi que le cas de perte de la KEK.
|
||||||
|
Il complète la spécification du coffre (§13, §14) ; il ne décrit
|
||||||
|
aucune valeur réelle — les exemples utilisent des valeurs fictives.
|
||||||
|
|
||||||
|
## 1. Ce qui constitue une sauvegarde
|
||||||
|
|
||||||
|
Deux fichiers, et seulement ces deux fichiers, portent le coffre :
|
||||||
|
|
||||||
|
| Fichier | Contenu |
|
||||||
|
| --- | --- |
|
||||||
|
| `data/coffres.json` | Coffres (métadonnées + DEK chiffrées), consommateurs (hashes de jetons seulement), octrois, métadonnées des secrets. |
|
||||||
|
| `data/coffres_valeurs.json` | Versions chiffrées des secrets (AES-256-GCM). |
|
||||||
|
|
||||||
|
Ces deux fichiers forment une **sauvegarde ordinaire chiffrée** :
|
||||||
|
copiés seuls, ils ne donnent accès à aucune valeur — chaque DEK est
|
||||||
|
chiffrée par la KEK, chaque valeur est chiffrée par la DEK de son
|
||||||
|
coffre. Ils peuvent donc transiter par la sauvegarde habituelle du
|
||||||
|
serveur, avec les mêmes permissions restrictives que sur l'instance
|
||||||
|
(0600). Ils ne doivent jamais être déposés dans le dépôt Git, ni dans
|
||||||
|
une note ObsiGate, ni dans un journal.
|
||||||
|
|
||||||
|
La **KEK ne fait pas partie de la sauvegarde** : elle est copiée à
|
||||||
|
part, hors serveur (voir §2). Sauvegarder les deux fichiers et la KEK
|
||||||
|
au même endroit annulerait la protection du chiffrement.
|
||||||
|
|
||||||
|
## 2. Sauvegarder la KEK (copie externe, hors serveur)
|
||||||
|
|
||||||
|
La KEK est une clé maître 256 bits (base64 de 32 octets), générée une
|
||||||
|
seule fois via `POST /api/coffres/admin/generer-kek` (admin, coffres
|
||||||
|
activés) et **jamais stockée par ObsiGate**. Au moment de sa
|
||||||
|
génération :
|
||||||
|
|
||||||
|
1. Copiez-la immédiatement dans un gestionnaire de mots de passe ou
|
||||||
|
sur un support hors ligne, hors du serveur ObsiGate.
|
||||||
|
2. Ne la collez jamais dans le dépôt, jamais dans une note ObsiGate,
|
||||||
|
jamais dans le même dossier que `data/`, jamais dans un journal.
|
||||||
|
3. Vérifiez la copie (relisez-la depuis le gestionnaire, pas depuis
|
||||||
|
l'écran qui ne l'affichera plus jamais).
|
||||||
|
|
||||||
|
Variables d'environnement (documentées aussi dans `.env.example`,
|
||||||
|
sans aucune valeur) :
|
||||||
|
|
||||||
|
| Variable | Rôle | Défaut |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `OBSIGATE_SECRETS_ENABLED` | Activation du coffre. Toute route répond 503 tant qu'elle n'est pas activée. | désactivé |
|
||||||
|
| `OBSIGATE_KEK` | KEK, base64 de 32 octets (ou hex 64 caractères). Prioritaire sur le fichier. | absente |
|
||||||
|
| `OBSIGATE_KEK_FILE` | Chemin d'un fichier 0600, hors dépôt et hors arborescence des vaults, contenant la KEK. Un fichier trop permissif est refusé. | absent |
|
||||||
|
| `OBSIGATE_DATA_DIR` | Répertoire des données (`data/` par défaut), donc des deux fichiers du coffre et de `api_keys.json`. | `data` |
|
||||||
|
|
||||||
|
**Aucune autre variable ne doit contenir une valeur de secret du
|
||||||
|
coffre.** En particulier, ne créez jamais de variable du type
|
||||||
|
`COFFRE_<nom>` avec une valeur : la seule valeur externe autorisée est
|
||||||
|
la KEK, et elle n'est pas une valeur de secret du coffre.
|
||||||
|
|
||||||
|
## 3. Restauration sur une instance (y compris vierge)
|
||||||
|
|
||||||
|
Prérequis : une instance ObsiGate avec le coffre activé
|
||||||
|
(`OBSIGATE_SECRETS_ENABLED`), et la **même KEK** que l'instance
|
||||||
|
d'origine.
|
||||||
|
|
||||||
|
1. Arrêtez l'instance cible (ou, à défaut, assurez-vous qu'aucune
|
||||||
|
écriture n'a lieu pendant la copie).
|
||||||
|
2. Restaurez les deux fichiers `coffres.json` et
|
||||||
|
`coffres_valeurs.json` dans le répertoire de données cible, en
|
||||||
|
permissions 0600.
|
||||||
|
3. Re-présentez la même KEK, via `OBSIGATE_KEK` ou via
|
||||||
|
`OBSIGATE_KEK_FILE` (fichier 0600, hors dépôt).
|
||||||
|
4. Redémarrez l'instance.
|
||||||
|
5. Vérifiez, sans jamais afficher ni journaliser de valeur :
|
||||||
|
- la vue Secrets liste les coffres et les secrets avec
|
||||||
|
`has_value = true` ;
|
||||||
|
- une distribution de test via un consommateur existant (ou un
|
||||||
|
consommateur de test, révoqué ensuite) aboutit — côté serveur,
|
||||||
|
le succès seul est vérifié, la valeur n'est ni affichée ni
|
||||||
|
consignée ;
|
||||||
|
- le journal d'audit contient `distribution_ok` pour cette
|
||||||
|
référence.
|
||||||
|
|
||||||
|
**Test de restauration obligatoire avant la mise en service réelle
|
||||||
|
(issue #20)** : la procédure ci-dessus a été automatisée dans la
|
||||||
|
suite de tests (`tests/test_coffre_secrets.py`, classe
|
||||||
|
`TestRestaurationVierge`) — copie des deux fichiers vers un
|
||||||
|
répertoire vierge, même KEK, distributions fonctionnelles — et doit
|
||||||
|
aussi être rejouée une fois sur une instance réelle par l'opérateur
|
||||||
|
avant de considérer la sauvegarde comme valide.
|
||||||
|
|
||||||
|
## 4. Perte de la KEK : perte d'accès assumée, par construction
|
||||||
|
|
||||||
|
Si la KEK est perdue, **il n'existe aucun moyen de récupérer les
|
||||||
|
valeurs** : les DEK ne sont stockées que chiffrées par la KEK, et les
|
||||||
|
valeurs ne sont stockées que chiffrées par les DEK (AES-256-GCM,
|
||||||
|
authentifié). Il n'y a ni clé de secours, ni dérivation depuis le
|
||||||
|
secret d'application, ni porte dérobée.
|
||||||
|
|
||||||
|
Procédure en cas de perte :
|
||||||
|
|
||||||
|
1. Considérez toutes les valeurs du coffre comme perdues.
|
||||||
|
2. Créez de nouveaux coffres avec une nouvelle KEK.
|
||||||
|
3. Pour chaque secret perdu, régénérez la valeur **côté fournisseur**
|
||||||
|
(nouvelle clé API, nouveau jeton) et créez un nouveau secret avec
|
||||||
|
cette nouvelle valeur ; révoquez les anciennes valeurs côté
|
||||||
|
fournisseur, car elles restent valides hors d'ObsiGate tant qu'elles
|
||||||
|
ne sont pas révoquées là-bas.
|
||||||
|
4. Recréez les consommateurs et les octrois (les anciens octrois
|
||||||
|
pointent vers des secrets qui n'existent plus dans les nouveaux
|
||||||
|
coffres).
|
||||||
|
5. Ne tentez pas de « réparer » les anciens fichiers avec une nouvelle
|
||||||
|
KEK : le déchiffrement échouera (tag GCM invalide), ce qui est le
|
||||||
|
comportement attendu, pas un bug.
|
||||||
|
|
||||||
|
La rotation KEK (`POST /api/coffres/admin/rotation-kek`) n'est pas
|
||||||
|
une récupération : elle exige l'ancienne KEK encore valide pour
|
||||||
|
re-chiffrer les DEK, puis le remplacement de la KEK dans
|
||||||
|
l'environnement ou le fichier KEK.
|
||||||
|
|
||||||
|
## 5. Migration `api_keys.json` (Phase 2) — avertissement important
|
||||||
|
|
||||||
|
Le bouton « Importer l'existant » de la vue Secrets (admin seulement)
|
||||||
|
copie les clés IA et clés d'outils de `data/api_keys.json` vers le
|
||||||
|
coffre d'instance « ObsiGate interne » (slug `obsigate-interne`),
|
||||||
|
en deux temps :
|
||||||
|
|
||||||
|
1. **Import** : les entrées avec valeur sont chiffrées dans le coffre
|
||||||
|
(un secret déjà présent n'est jamais écrasé) ; `api_keys.json`
|
||||||
|
n'est pas modifié.
|
||||||
|
2. **Purge** : après confirmation explicite, et uniquement pour les
|
||||||
|
noms migrés, les entrées sont supprimées du clair. Sans
|
||||||
|
confirmation, rien n'est supprimé — jamais automatiquement.
|
||||||
|
|
||||||
|
**Avertissement** : les lecteurs actuels d'ObsiGate
|
||||||
|
(`backend/ai.py`, `backend/tools/secrets.py`) lisent encore
|
||||||
|
`api_keys.json` directement ; ils ne sont pas encore branchés sur le
|
||||||
|
coffre. Purger le clair prive donc ces lecteurs de la clé tant que
|
||||||
|
leur lecteur n'est pas branché sur une distribution depuis le coffre.
|
||||||
|
Ne purgez que si vous avez vérifié que le consommateur concerné
|
||||||
|
distribue depuis le coffre, ou si vous acceptez cette interruption.
|
||||||
|
Ce branchement des lecteurs internes est un reste à faire explicite
|
||||||
|
(voir §6).
|
||||||
|
|
||||||
|
## 6. Restes à faire (après cette Phase 2)
|
||||||
|
|
||||||
|
- Brancher les lecteurs internes (`ai.py`, `tools/secrets.py`) sur
|
||||||
|
une distribution depuis le coffre d'instance, pour que la purge du
|
||||||
|
clair ne coupe pas l'IA ni les outils.
|
||||||
|
- Rejouer le test de restauration sur une instance réelle (la suite
|
||||||
|
automatisée simule une instance vierge, elle ne remplace pas un
|
||||||
|
essai sur l'instance de production).
|
||||||
|
- Endpoint de distribution par session utilisateur (copie manuelle
|
||||||
|
sans consommateur temporaire) : non ajouté ici, car il contredirait
|
||||||
|
la règle Phase 1b (distribution réservée aux consommateurs) et
|
||||||
|
exigerait une ré-authentification qui n'existe pas encore — décision
|
||||||
|
à prendre avec l'opérateur avant toute implémentation.
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.79.0 | **Dernière mise à jour :** 2026-10-26
|
> **Version :** 2.81.0 | **Dernière mise à jour :** 2026-10-28
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
|
|||||||
+4
-4
@@ -19,8 +19,8 @@
|
|||||||
var mode = localStorage.getItem("obsigate-theme-mode") || "dark";
|
var mode = localStorage.getItem("obsigate-theme-mode") || "dark";
|
||||||
root.setAttribute("data-theme", mode);
|
root.setAttribute("data-theme", mode);
|
||||||
var light = mode === "light" || mode === "sepia";
|
var light = mode === "light" || mode === "sepia";
|
||||||
root.style.setProperty("--bg-primary", light ? "#ffffff" : "#0f1117");
|
root.style.setProperty("--bg-primary", light ? "#fbfbfa" : "#17181a");
|
||||||
root.style.setProperty("--text-primary", light ? "#1a1a1a" : "#e6edf3");
|
root.style.setProperty("--text-primary", light ? "#242624" : "#eceeeb");
|
||||||
} catch (e) { root.setAttribute("data-theme", "dark"); }
|
} catch (e) { root.setAttribute("data-theme", "dark"); }
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
overflow: auto; /* override SPA style.css: body{overflow:hidden} */
|
overflow: auto; /* override SPA style.css: body{overflow:hidden} */
|
||||||
height: auto; /* override SPA style.css: body{height:100vh} */
|
height: auto; /* override SPA style.css: body{height:100vh} */
|
||||||
background: var(--bg-primary, #0f1117);
|
background: var(--bg-primary, #17181a);
|
||||||
color: var(--text-primary, #e6edf3);
|
color: var(--text-primary, #eceeeb);
|
||||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+89
-17
@@ -37,16 +37,19 @@
|
|||||||
display: flex; align-items: center; gap: 9px;
|
display: flex; align-items: center; gap: 9px;
|
||||||
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
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-titres { min-width: 0; flex: 1; }
|
||||||
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
|
.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 {
|
.draco-panneau-hd .fermer {
|
||||||
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
|
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) */
|
/* Barre des discussions (façon Muse) */
|
||||||
.draco-disc-bar {
|
.draco-disc-bar {
|
||||||
display: flex; align-items: center; gap: 7px;
|
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.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.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-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-ft { padding: 10px 12px 11px; background: var(--p-fond); }
|
||||||
.draco-panneau-form { display: flex; gap: 8px; }
|
/* Puces de suggestions, comme la console : au-dessus du composer. */
|
||||||
.draco-panneau-form input {
|
.draco-puces {
|
||||||
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
|
display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; padding: 0 2px 8px;
|
||||||
font: inherit; background: var(--p-champ); color: var(--p-texte);
|
|
||||||
}
|
}
|
||||||
.draco-panneau-form button {
|
.draco-puces button {
|
||||||
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
|
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||||
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
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 { 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 h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
|
||||||
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
||||||
|
|||||||
+332
-23
@@ -40,7 +40,10 @@
|
|||||||
var convKind = null; // 'principale' | 'parallele'
|
var convKind = null; // 'principale' | 'parallele'
|
||||||
var convTitre = "";
|
var convTitre = "";
|
||||||
var etatConnu = false;
|
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 root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||||
|
var microEl = null, piecesEl = null, fichierEl = null;
|
||||||
var titreEl = null, tiroirEl = null;
|
var titreEl = null, tiroirEl = null;
|
||||||
var versionEl = null, versionConnue = "", versionMaj = 0;
|
var versionEl = null, versionConnue = "", versionMaj = 0;
|
||||||
|
|
||||||
@@ -68,11 +71,16 @@
|
|||||||
return correspondance[h] || null;
|
return correspondance[h] || null;
|
||||||
}
|
}
|
||||||
function urlConsole() {
|
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 params = [];
|
||||||
var id = convId || convStockee();
|
var id = convId || convStockee();
|
||||||
if (id) params.push("disc=" + encodeURIComponent(id));
|
if (id) params.push("disc=" + encodeURIComponent(id));
|
||||||
var app = appHote();
|
var app = appHote();
|
||||||
if (app) params.push("app=" + encodeURIComponent(app));
|
if (app) params.push("app=" + encodeURIComponent(app));
|
||||||
|
params.push("domain=" + encodeURIComponent(domaine()));
|
||||||
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +155,15 @@
|
|||||||
|
|
||||||
function ouvrirConsole() {
|
function ouvrirConsole() {
|
||||||
assurerSurimpression();
|
assurerSurimpression();
|
||||||
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
|
/* 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;
|
if (surOuverte) return;
|
||||||
surOuverte = true;
|
surOuverte = true;
|
||||||
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
||||||
@@ -175,7 +191,12 @@
|
|||||||
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
||||||
surEl.style.width = largeurPanneau() + "px";
|
surEl.style.width = largeurPanneau() + "px";
|
||||||
}
|
}
|
||||||
function modeSidebarConsole() { retrecirConsole(); }
|
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(); }
|
function fermerAgentConsole() { fermer(); retrecirConsole(); }
|
||||||
|
|
||||||
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
||||||
@@ -246,13 +267,10 @@
|
|||||||
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
|
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
|
||||||
'<div class="draco-panneau-hd">' +
|
'<div class="draco-panneau-hd">' +
|
||||||
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
||||||
' <div><div class="titre">Spark — agent commun</div>' +
|
' <div class="draco-panneau-titres"><div class="titre">Spark — agent commun</div>' +
|
||||||
' <div class="soustitre">Service d\'agents dracodev</div></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>' +
|
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
|
||||||
'</div>' +
|
'</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">' +
|
'<div class="draco-disc-bar">' +
|
||||||
' <span class="d-titre">Discussion principale</span>' +
|
' <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="liste">☰ Discussions</button>' +
|
||||||
@@ -266,17 +284,40 @@
|
|||||||
' <button type="button" data-outil="missions">🚀 Missions</button>' +
|
' <button type="button" data-outil="missions">🚀 Missions</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="draco-panneau-msgs"></div>' +
|
'<div class="draco-panneau-msgs"></div>' +
|
||||||
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
|
'<div class="draco-panneau-ft">' +
|
||||||
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
' <div class="draco-puces">' +
|
||||||
' <button type="submit">Envoyer</button></form>' +
|
' <button type="button" data-sugg="resume">Résumer ma journée</button>' +
|
||||||
' <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>' +
|
' <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 class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
msgsEl = root.querySelector(".draco-panneau-msgs");
|
msgsEl = root.querySelector(".draco-panneau-msgs");
|
||||||
etatEl = root.querySelector(".draco-panneau-etat");
|
etatEl = root.querySelector(".draco-panneau-hd .soustitre");
|
||||||
inputEl = root.querySelector("input");
|
inputEl = root.querySelector(".draco-composer textarea");
|
||||||
sendBtn = root.querySelector("button[type=submit]");
|
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");
|
titreEl = root.querySelector(".d-titre");
|
||||||
tiroirEl = root.querySelector(".draco-discussions");
|
tiroirEl = root.querySelector(".draco-discussions");
|
||||||
versionEl = root.querySelector(".draco-panneau-version");
|
versionEl = root.querySelector(".draco-panneau-version");
|
||||||
@@ -288,6 +329,42 @@
|
|||||||
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
||||||
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
||||||
root.querySelector("form").addEventListener("submit", envoyer);
|
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"));
|
brancherPoignee(root.querySelector(".draco-poignee"));
|
||||||
appliquerTheme();
|
appliquerTheme();
|
||||||
surveillerTheme();
|
surveillerTheme();
|
||||||
@@ -326,8 +403,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function majEtat(cls, texte) {
|
function majEtat(cls, texte) {
|
||||||
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
/* Statut intégré au titre (sous-titre de l'en-tête) : plus de
|
||||||
etatEl.querySelector(".txt").textContent = texte;
|
* 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() {
|
function majTitre() {
|
||||||
titreEl.textContent = convTitre || "Discussion principale";
|
titreEl.textContent = convTitre || "Discussion principale";
|
||||||
@@ -400,9 +482,21 @@
|
|||||||
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
.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() {
|
function demarrerDiscussions() {
|
||||||
chargerListeDiscussions()
|
chargerListeDiscussions()
|
||||||
.then(function (d) {
|
.then(function (d) {
|
||||||
|
domaineCharge = domaine();
|
||||||
var precedente = convStockee();
|
var precedente = convStockee();
|
||||||
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
||||||
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
||||||
@@ -413,6 +507,7 @@
|
|||||||
chargerHistorique();
|
chargerHistorique();
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
|
domaineCharge = null; // échec : la prochaine ouverture réessaiera
|
||||||
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -780,19 +875,232 @@
|
|||||||
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function envoyer(ev) {
|
/* ── Composer façon console ────────────────────────────────
|
||||||
ev.preventDefault();
|
* Mêmes règles que la console : + (images/documents, 4 max),
|
||||||
var texte = inputEl.value.trim();
|
* textarea auto-extensible, micro affiché seulement sans texte
|
||||||
if (!texte) return;
|
* 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 = "";
|
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;
|
sendBtn.disabled = true;
|
||||||
var attente = ajouterMsg("agent", "Spark réfléchit…");
|
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", {
|
fetch(API + "/api/v1/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
|
body: JSON.stringify(corps)
|
||||||
})
|
})
|
||||||
.then(function (r) {
|
.then(function (r) {
|
||||||
if (r.status === 401) {
|
if (r.status === 401) {
|
||||||
@@ -831,11 +1139,12 @@
|
|||||||
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
|
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
|
||||||
majEtat("err", "Service d'agents injoignable");
|
majEtat("err", "Service d'agents injoignable");
|
||||||
})
|
})
|
||||||
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
|
.finally(function () { sendBtn.disabled = false; majComposer(); inputEl.focus(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function ouvrir() {
|
function ouvrir() {
|
||||||
if (!root) { construire(); demarrerDiscussions(); }
|
if (!root) { construire(); demarrerDiscussions(); }
|
||||||
|
else resynchroniserDomaine();
|
||||||
root.classList.add("open");
|
root.classList.add("open");
|
||||||
appliquerTheme();
|
appliquerTheme();
|
||||||
verifierIdentite();
|
verifierIdentite();
|
||||||
|
|||||||
+4
-4
@@ -10,7 +10,7 @@
|
|||||||
name="description"
|
name="description"
|
||||||
content="Porte d'entrée web pour vos vaults Obsidian - Accédez, naviguez et recherchez dans toutes vos notes"
|
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="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta
|
<meta
|
||||||
@@ -54,14 +54,14 @@
|
|||||||
#boot-splash {
|
#boot-splash {
|
||||||
position: fixed; inset: 0; z-index: 2147483647;
|
position: fixed; inset: 0; z-index: 2147483647;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
background: radial-gradient(1200px 600px at 50% -10%, #1b2030 0%, #0e1117 60%);
|
background: radial-gradient(1200px 600px at 50% -10%, #26272b 0%, #17181a 60%);
|
||||||
color: #e6e8ee; font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
color: #eceeeb; font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
body.app-booted #boot-splash { display: none; }
|
body.app-booted #boot-splash { display: none; }
|
||||||
.bs-card { display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
.bs-card { display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
||||||
.bs-logo {
|
.bs-logo {
|
||||||
width: 72px; height: 72px; display: flex; align-items: center; justify-content: center;
|
width: 72px; height: 72px; display: flex; align-items: center; justify-content: center;
|
||||||
font-size: 38px; background: #171c26; border: 1px solid #2a3242; border-radius: 18px;
|
font-size: 38px; background: #1f2023; border: 1px solid #33353a; border-radius: 18px;
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
.bs-title { font-size: 22px; font-weight: 600; letter-spacing: 0.3px; }
|
.bs-title { font-size: 22px; font-weight: 600; letter-spacing: 0.3px; }
|
||||||
|
|||||||
@@ -18,6 +18,11 @@
|
|||||||
* _copieManuelle) passe par un consommateur temporaire créé puis révoqué
|
* _copieManuelle) passe par un consommateur temporaire créé puis révoqué
|
||||||
* immédiatement, avec distribution auditée ; il n'existe pas encore
|
* immédiatement, avec distribution auditée ; il n'existe pas encore
|
||||||
* d'endpoint de distribution par session utilisateur côté backend.
|
* d'endpoint de distribution par session utilisateur côté backend.
|
||||||
|
* - Phase 2 (issue #20) : « Importer l'existant » migre les clés de
|
||||||
|
* data/api_keys.json vers le coffre d'instance « ObsiGate interne »,
|
||||||
|
* en deux temps (import, puis purge confirmée). Aucune valeur ne
|
||||||
|
* transite jamais par cette vue : l'aperçu et les résultats ne
|
||||||
|
* contiennent que des noms et des comptes (has_value).
|
||||||
* - La vue est rendue directement dans #content-area : elle ne passe ni
|
* - La vue est rendue directement dans #content-area : elle ne passe ni
|
||||||
* par TabManager.openNav (navigation de fichiers), ni par l'indexeur,
|
* par TabManager.openNav (navigation de fichiers), ni par l'indexeur,
|
||||||
* ni par la recherche, ni par le graphe.
|
* ni par la recherche, ni par le graphe.
|
||||||
@@ -292,6 +297,9 @@ function valeurField(initialType) {
|
|||||||
else if (input) input.type = 'password';
|
else if (input) input.type = 'password';
|
||||||
});
|
});
|
||||||
eyeBtn.title = t('coffres.show_before_save');
|
eyeBtn.title = t('coffres.show_before_save');
|
||||||
|
// L'oeil doit etre enfant de wrap AVANT le premier build : insertBefore(input, eyeBtn)
|
||||||
|
// leve sinon NotFoundError et la modale ne s'ouvre jamais (bug bouton Nouveau secret).
|
||||||
|
wrap.appendChild(eyeBtn);
|
||||||
build(initialType);
|
build(initialType);
|
||||||
return {
|
return {
|
||||||
wrap,
|
wrap,
|
||||||
@@ -862,6 +870,83 @@ async function copieManuelle(s) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Phase 2 — « Importer l'existant » (admin seulement, deux temps).
|
||||||
|
* 1. Aperçu (métadonnées seules, jamais de valeur) puis import vers le
|
||||||
|
* coffre d'instance. L'import ne supprime rien dans api_keys.json.
|
||||||
|
* 2. Purge du clair, uniquement après confirmation explicite, et
|
||||||
|
* uniquement pour les noms effectivement migrés. L'avertissement est
|
||||||
|
* affiché avant l'import : 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 (voir la documentation
|
||||||
|
* de sauvegarde/restauration). */
|
||||||
|
async function importerExistant() {
|
||||||
|
if (!isAdmin()) return;
|
||||||
|
let apercu;
|
||||||
|
try {
|
||||||
|
apercu = await api('/api/coffres/migration/api-keys');
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || t('common.error'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const candidats = apercu.candidats || [];
|
||||||
|
const lignes = candidats.map((c) => {
|
||||||
|
const etat = c.deja_existant
|
||||||
|
? t('coffres.import_already_exists')
|
||||||
|
: (c.has_value ? t('coffres.import_has_value') : t('coffres.import_no_value'));
|
||||||
|
return `${c.nom} (${c.service}) — ${etat}`;
|
||||||
|
});
|
||||||
|
const body = el('div', {}, [
|
||||||
|
text('p', t('coffres.import_warning'), 'modal-hint'),
|
||||||
|
...lignes.map((ligne) => text('p', ligne, '')),
|
||||||
|
]);
|
||||||
|
const m = openModal(t('coffres.import_title'), body, [
|
||||||
|
modalBtn(t('common.cancel'), '', () => m.close()),
|
||||||
|
modalBtn(t('coffres.import_existing'), 'primary', async () => {
|
||||||
|
let resultat;
|
||||||
|
try {
|
||||||
|
resultat = await api('/api/coffres/migration/api-keys/import', { method: 'POST', body: '{}' });
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || t('common.error'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m.close();
|
||||||
|
const migres = resultat.migres || [];
|
||||||
|
const resume = [
|
||||||
|
`${t('coffres.import_done')} — ${t('coffres.import_migrated')}: ${migres.length}, ${t('coffres.import_already_exists')}: ${(resultat.deja_existants || []).length}, ${t('coffres.import_no_value')}: ${(resultat.sans_valeur || []).length}`,
|
||||||
|
...migres.map((nom) => `${nom} — ${t('coffres.import_migrated')}`),
|
||||||
|
];
|
||||||
|
const rb = el('div', {}, resume.map((ligne) => text('p', ligne, '')));
|
||||||
|
const boutons = [modalBtn(t('common.close'), 'primary', async () => {
|
||||||
|
rm.close();
|
||||||
|
await openCoffresView('obsigate-interne');
|
||||||
|
})];
|
||||||
|
if (migres.length) {
|
||||||
|
boutons.unshift(modalBtn(t('coffres.import_purge'), 'danger', async () => {
|
||||||
|
const ok = await showConfirm({
|
||||||
|
title: t('coffres.import_purge'),
|
||||||
|
message: `${t('coffres.import_purge_confirm')} ${migres.join(', ')}`,
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
const purge = await api('/api/coffres/migration/api-keys/purger', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ confirmer: true, noms: migres }),
|
||||||
|
});
|
||||||
|
showToast(`${t('coffres.import_purged')} (${(purge.supprimes || []).length})`, 'success');
|
||||||
|
} catch (e) {
|
||||||
|
showToast(e.message || t('common.error'), 'error');
|
||||||
|
}
|
||||||
|
rm.close();
|
||||||
|
await openCoffresView('obsigate-interne');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const rm = openModal(t('coffres.import_title'), rb, boutons);
|
||||||
|
void rm;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
function openKekModal(mode) {
|
function openKekModal(mode) {
|
||||||
if (!isAdmin()) return;
|
if (!isAdmin()) return;
|
||||||
if (mode === 'generer') {
|
if (mode === 'generer') {
|
||||||
@@ -999,6 +1084,12 @@ export async function openCoffresView(slug, options = {}) {
|
|||||||
});
|
});
|
||||||
toolbar.appendChild(search);
|
toolbar.appendChild(search);
|
||||||
toolbar.appendChild(modalBtn(t('coffres.new_secret'), 'primary', () => openCreateSecretModal(coffre.slug)));
|
toolbar.appendChild(modalBtn(t('coffres.new_secret'), 'primary', () => openCreateSecretModal(coffre.slug)));
|
||||||
|
// Phase 2 : migration api_keys.json -> coffre d'instance (admin seulement).
|
||||||
|
// Le bouton est visible quel que soit le coffre affiché : la cible est
|
||||||
|
// toujours le coffre d'instance « ObsiGate interne ».
|
||||||
|
if (isAdmin()) {
|
||||||
|
toolbar.appendChild(modalBtn(t('coffres.import_existing'), '', () => importerExistant()));
|
||||||
|
}
|
||||||
toolbar.appendChild(modalBtn(t('coffres.dek_rotate'), '', async () => {
|
toolbar.appendChild(modalBtn(t('coffres.dek_rotate'), '', async () => {
|
||||||
const ok = await showConfirm({ title: t('coffres.dek_rotate'), message: t('coffres.confirm_dek_rotate'), danger: true });
|
const ok = await showConfirm({ title: t('coffres.dek_rotate'), message: t('coffres.confirm_dek_rotate'), danger: true });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ function _sharedIcon(size = 16) {
|
|||||||
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
|
// renommer/déplacer/supprimer). Le menu ⋮ est un menu propre ci-dessous.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Icône cadenas (U+1F510) du nœud Secrets, distincte de la maison et du diamant. */
|
/** 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() {
|
function _secretsIcon() {
|
||||||
const s = document.createElement("span");
|
const s = document.createElement("span");
|
||||||
s.className = "coffres-icon-lock";
|
s.className = "coffres-icon-lock";
|
||||||
s.textContent = "\u{1F510}";
|
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;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+68
-3
@@ -1,5 +1,5 @@
|
|||||||
/* ObsiGate — Theme System
|
/* ObsiGate — Theme System
|
||||||
* 15 themes x 2 modes (dark/light)
|
* 16 themes x 2 modes (dark/light)
|
||||||
* Persisted in localStorage as 'obsigate-theme'
|
* Persisted in localStorage as 'obsigate-theme'
|
||||||
* Usage: import { initThemes, applyTheme, getCurrentTheme, THEMES } from './themes.js'
|
* 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': {
|
'github': {
|
||||||
name: 'GitHub', desc: 'Contraste doux',
|
name: 'GitHub', desc: 'Contraste doux',
|
||||||
modes: {
|
modes: {
|
||||||
@@ -523,7 +588,7 @@ var THEMES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Theme Engine ─────────────────────────────────────────────────────
|
// ── Theme Engine ─────────────────────────────────────────────────────
|
||||||
var _currentTheme = 'defaut-obsigate';
|
var _currentTheme = 'spark';
|
||||||
var _currentMode = 'dark';
|
var _currentMode = 'dark';
|
||||||
|
|
||||||
var AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia'];
|
var AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia'];
|
||||||
@@ -588,7 +653,7 @@ function applyTheme(themeKey, mode) {
|
|||||||
root.style.setProperty(key, vars[key]);
|
root.style.setProperty(key, vars[key]);
|
||||||
});
|
});
|
||||||
// #180 — the spreadsheet grid follows the active theme: derive its tokens
|
// #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.
|
// 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.
|
// 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
|
// #185 — `--grid-border` no longer reuses `--border` (which sits 1-2% off the
|
||||||
|
|||||||
@@ -2757,5 +2757,16 @@
|
|||||||
"coffres.type": "Type",
|
"coffres.type": "Type",
|
||||||
"coffres.value": "Value",
|
"coffres.value": "Value",
|
||||||
"coffres.value_required": "A value is required.",
|
"coffres.value_required": "A value is required.",
|
||||||
"coffres.version": "Version"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2757,5 +2757,16 @@
|
|||||||
"coffres.type": "Type",
|
"coffres.type": "Type",
|
||||||
"coffres.value": "Valeur",
|
"coffres.value": "Valeur",
|
||||||
"coffres.value_required": "Une valeur est requise.",
|
"coffres.value_required": "Une valeur est requise.",
|
||||||
"coffres.version": "Version"
|
"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é"
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-45
@@ -11,58 +11,58 @@
|
|||||||
|
|
||||||
/* ── Editor save dot state colors ── */
|
/* ── Editor save dot state colors ── */
|
||||||
:root {
|
:root {
|
||||||
--ok: #34d399;
|
--ok: #7fd6a8;
|
||||||
--warn: #fbbf24;
|
--warn: #e8a13c;
|
||||||
--ai: #8b5cf6;
|
--ai: #7c5cd6;
|
||||||
--err: #f87171;
|
--err: #cf4b32;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== THEME — DARK (fallback default, no data-theme) ===== */
|
/* ===== THEME — DARK (fallback default, no data-theme) ===== */
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #0f1117;
|
--bg-primary: #17181a;
|
||||||
--bg-secondary: #161b22;
|
--bg-secondary: #26272b;
|
||||||
--bg-sidebar: #13161d;
|
--bg-sidebar: #17181a;
|
||||||
--bg-hover: #1f2430;
|
--bg-hover: #2e3036;
|
||||||
--border: #21262d;
|
--border: #33353a;
|
||||||
--text-primary: #e6edf3;
|
--text-primary: #eceeeb;
|
||||||
--text-secondary: #8b949e;
|
--text-secondary: #a6aba0;
|
||||||
--text-muted: #484f58;
|
--text-muted: #83887e;
|
||||||
--accent: #58a6ff;
|
--accent: #7c5cd6;
|
||||||
--accent-green: #3fb950;
|
--accent-green: #2ea36b;
|
||||||
--tag-bg: #1f2a3a;
|
--tag-bg: #26272b;
|
||||||
--tag-text: #79c0ff;
|
--tag-text: #7c5cd6;
|
||||||
--code-bg: #161b22;
|
--code-bg: #2c2e33;
|
||||||
--search-bg: #21262d;
|
--search-bg: #1f2023;
|
||||||
--scrollbar: #30363d;
|
--scrollbar: #3a3c41;
|
||||||
--resize-handle: #30363d;
|
--resize-handle: #3a3c41;
|
||||||
--overlay-bg: rgba(0, 0, 0, 0.5);
|
--overlay-bg: rgba(0, 0, 0, 0.5);
|
||||||
--danger: #ff7b72;
|
--danger: #e8a08d;
|
||||||
--danger-bg: #3d1a18;
|
--danger-bg: #40241f;
|
||||||
--success: #3fb950;
|
--success: #2ea36b;
|
||||||
--success-bg: #1a3d1f;
|
--success-bg: #173d29;
|
||||||
--accent-card: #58a6ff;
|
--accent-card: #7c5cd6;
|
||||||
--accent-bg: #1f2a3a;
|
--accent-bg: #26272b;
|
||||||
--accent-text: #79c0ff;
|
--accent-text: #b39df5;
|
||||||
--green: #3fb950;
|
--green: #2ea36b;
|
||||||
--green-bg: #1a3d1f;
|
--green-bg: #173d29;
|
||||||
--purple: #a371f7;
|
--purple: #7c5cd6;
|
||||||
--purple-bg: #2d1b4e;
|
--purple-bg: #2a2118;
|
||||||
--surface: #161b22;
|
--surface: #1f2023;
|
||||||
--surface2: #0f1117;
|
--surface2: #17181a;
|
||||||
--surface3: #0d0f14;
|
--surface3: #26272b;
|
||||||
--border-md: #30363d;
|
--border-md: #3a3c41;
|
||||||
--text: #e6edf3;
|
--text: #eceeeb;
|
||||||
--text-2: #8b949e;
|
--text-2: #a6aba0;
|
||||||
--text-3: #484f58;
|
--text-3: #83887e;
|
||||||
--mono: "JetBrains Mono", monospace;
|
--mono: "JetBrains Mono", monospace;
|
||||||
--radius: 6px;
|
--radius: 6px;
|
||||||
--radius-lg: 10px;
|
--radius-lg: 10px;
|
||||||
/* #154-A1 — data-grid tokens: the sheet reads slightly lighter than the
|
/* #154-A1 — data-grid tokens: the sheet reads slightly lighter than the
|
||||||
chrome (toolbar/panels), headers are clearly distinct from cells. */
|
chrome (toolbar/panels), headers are clearly distinct from cells. */
|
||||||
--grid-bg: #161b22;
|
--grid-bg: #1f2023;
|
||||||
--grid-header-bg: #1f2430;
|
--grid-header-bg: #2e3036;
|
||||||
--grid-header-text: #e6edf3;
|
--grid-header-text: #eceeeb;
|
||||||
--grid-border: #3d444d;
|
--grid-border: #33353a;
|
||||||
--grid-zebra: rgba(255, 255, 255, 0.025);
|
--grid-zebra: rgba(255, 255, 255, 0.025);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7700,6 +7700,8 @@ body.popup-mode .content-area {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 200ms ease;
|
transition: opacity 200ms ease;
|
||||||
@@ -7715,8 +7717,16 @@ body.popup-mode .content-area {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||||
min-width: 400px;
|
/* Correctif Phase 2 (modale de création de secret coupée en haut) :
|
||||||
max-width: 600px;
|
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);
|
transform: scale(0.9);
|
||||||
transition: transform 200ms ease;
|
transition: transform 200ms ease;
|
||||||
}
|
}
|
||||||
@@ -7731,6 +7741,7 @@ body.popup-mode .content-area {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obsigate-modal-title {
|
.obsigate-modal-title {
|
||||||
@@ -7742,6 +7753,8 @@ body.popup-mode .content-area {
|
|||||||
|
|
||||||
.obsigate-modal-body {
|
.obsigate-modal-body {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.obsigate-modal-footer {
|
.obsigate-modal-footer {
|
||||||
@@ -7750,6 +7763,7 @@ body.popup-mode .content-area {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-input {
|
.modal-input {
|
||||||
@@ -14358,7 +14372,8 @@ body.has-draco-bar .app-container { height: calc(100vh - 60px); }
|
|||||||
.coffres-alerts { margin: 12px 0; }
|
.coffres-alerts { margin: 12px 0; }
|
||||||
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
|
.coffres-alert { color: var(--warning, #f59e0b); padding: 2px 0; }
|
||||||
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; }
|
.coffres-form { display: grid; gap: 8px; max-width: 560px; margin: 8px 0; }
|
||||||
.coffres-icon-lock { font-size: 16px; line-height: 1; }
|
.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-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-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 { display: block; width: 100%; text-align: left; padding: 8px 12px; background: transparent; border: none; color: var(--text-primary, #e6edf3); cursor: pointer; border-radius: 6px; }
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.79.0",
|
"version": "2.81.0",
|
||||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -455,3 +456,225 @@ class TestDistribution:
|
|||||||
assert "secret_cree" in actions
|
assert "secret_cree" in actions
|
||||||
assert "distribution_ok" in actions
|
assert "distribution_ok" in actions
|
||||||
assert "valeur" not in json.dumps(resp.json())
|
assert "valeur" not in json.dumps(resp.json())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2 — Migration api_keys.json (issue #20)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMigrationApiKeys:
|
||||||
|
def _ecrire_api_keys(self, tmp_path):
|
||||||
|
(tmp_path / "api_keys.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"DEEPSEEK_API_KEY": VALEUR_TEST,
|
||||||
|
"OBSIGATE_GITEA_TOKEN": VALEUR_TEST_2,
|
||||||
|
"OBSIGATE_GITEA_URL": "https://example.invalid",
|
||||||
|
"MISTRAL_API_KEY": "",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_apercu_import_purge(self, client, env_coffre):
|
||||||
|
self._ecrire_api_keys(env_coffre)
|
||||||
|
resp = client.get("/api/coffres/migration/api-keys")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
# Aperçu = métadonnées seules, jamais une valeur.
|
||||||
|
assert VALEUR_TEST not in resp.text
|
||||||
|
assert VALEUR_TEST_2 not in resp.text
|
||||||
|
candidats = {c["nom"]: c for c in resp.json()["candidats"]}
|
||||||
|
assert candidats["deepseek_api_key"]["has_value"] is True
|
||||||
|
assert candidats["mistral_api_key"]["has_value"] is False
|
||||||
|
assert "obsigate_gitea_url" not in candidats # URL, pas un secret
|
||||||
|
# Import : ne supprime rien dans api_keys.json.
|
||||||
|
resp = client.post("/api/coffres/migration/api-keys/import", json={})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
resultat = resp.json()
|
||||||
|
assert resultat["coffre"] == "obsigate-interne"
|
||||||
|
assert "deepseek_api_key" in resultat["migres"]
|
||||||
|
assert "obsigate_gitea_token" in resultat["migres"]
|
||||||
|
assert "mistral_api_key" in resultat["sans_valeur"]
|
||||||
|
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
|
||||||
|
assert cles["DEEPSEEK_API_KEY"] == VALEUR_TEST
|
||||||
|
# Les secrets migrés sont visibles en métadonnées et distribuables.
|
||||||
|
resp = client.get("/api/coffres/obsigate-interne/secrets")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert VALEUR_TEST not in resp.text
|
||||||
|
assert any(s["nom"] == "deepseek_api_key" and s["has_value"] for s in resp.json())
|
||||||
|
_, jeton, _ = _creer_consommateur_octroi(
|
||||||
|
client, slug="obsigate-interne", nom="deepseek_api_key"
|
||||||
|
)
|
||||||
|
resp = _distribuer(
|
||||||
|
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["valeur"] == VALEUR_TEST
|
||||||
|
# Purge sans confirmation : refusée, fichier intact.
|
||||||
|
resp = client.post(
|
||||||
|
"/api/coffres/migration/api-keys/purger",
|
||||||
|
json={"confirmer": False, "noms": ["deepseek_api_key"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
|
||||||
|
assert "DEEPSEEK_API_KEY" in cles
|
||||||
|
# Purge confirmée : seules les clés listées disparaissent.
|
||||||
|
resp = client.post(
|
||||||
|
"/api/coffres/migration/api-keys/purger",
|
||||||
|
json={"confirmer": True, "noms": ["deepseek_api_key", "obsigate_gitea_token"]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert set(resp.json()["supprimes"]) == {
|
||||||
|
"deepseek_api_key",
|
||||||
|
"obsigate_gitea_token",
|
||||||
|
}
|
||||||
|
cles = json.loads((env_coffre / "api_keys.json").read_text(encoding="utf-8"))
|
||||||
|
assert "DEEPSEEK_API_KEY" not in cles
|
||||||
|
assert "OBSIGATE_GITEA_TOKEN" not in cles
|
||||||
|
assert cles["OBSIGATE_GITEA_URL"] == "https://example.invalid"
|
||||||
|
# Audit sans valeur.
|
||||||
|
_fichiers_sans_valeur(env_coffre)
|
||||||
|
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
|
||||||
|
assert "migration_api_keys" in audit
|
||||||
|
assert "migration_api_keys_purge" in audit
|
||||||
|
|
||||||
|
def test_import_ne_ecrase_pas_existant(self, client, env_coffre):
|
||||||
|
self._ecrire_api_keys(env_coffre)
|
||||||
|
assert client.post("/api/coffres/migration/api-keys/import", json={}).status_code == 200
|
||||||
|
(env_coffre / "api_keys.json").write_text(
|
||||||
|
json.dumps({"DEEPSEEK_API_KEY": VALEUR_TEST_2}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
resp = client.post("/api/coffres/migration/api-keys/import", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "deepseek_api_key" in resp.json()["deja_existants"]
|
||||||
|
_, jeton, _ = _creer_consommateur_octroi(
|
||||||
|
client, slug="obsigate-interne", nom="deepseek_api_key"
|
||||||
|
)
|
||||||
|
resp = _distribuer(
|
||||||
|
client, jeton, reference="coffre:obsigate-interne/deepseek_api_key"
|
||||||
|
)
|
||||||
|
assert resp.json()["valeur"] == VALEUR_TEST # valeur d'origine conservée
|
||||||
|
|
||||||
|
def test_import_sans_kek_repond_503(self, client, env_coffre, monkeypatch):
|
||||||
|
# Une clé à migrer est nécessaire : sans valeur à chiffrer,
|
||||||
|
# l'import réussit trivialement même sans KEK (mode dégradé).
|
||||||
|
self._ecrire_api_keys(env_coffre)
|
||||||
|
monkeypatch.delenv("OBSIGATE_KEK", raising=False)
|
||||||
|
resp = client.post("/api/coffres/migration/api-keys/import", json={})
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert resp.json()["detail"] == "kek_absente"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 2 — Restauration sur instance vierge (issue #20)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRestaurationVierge:
|
||||||
|
def test_restauration_meme_kek_distribue(self, env_coffre, tmp_path):
|
||||||
|
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||||
|
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||||
|
assert distribuer_valeur(coffre, secret) == VALEUR_TEST
|
||||||
|
# Simulation d'une instance vierge : seuls les deux fichiers sont
|
||||||
|
# copiés, la KEK (variable d'environnement) est inchangée.
|
||||||
|
restaure = tmp_path / "restaure"
|
||||||
|
restaure.mkdir()
|
||||||
|
for nom in ("coffres.json", "coffres_valeurs.json"):
|
||||||
|
shutil.copy(env_coffre / nom, restaure / nom)
|
||||||
|
coffre_r = get_coffre_or_404("personnel", restaure)
|
||||||
|
assert distribuer_valeur(coffre_r, secret, restaure) == VALEUR_TEST
|
||||||
|
for nom in ("coffres.json", "coffres_valeurs.json"):
|
||||||
|
assert VALEUR_TEST not in (restaure / nom).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_restauration_mauvaise_kek_refuse(self, env_coffre, tmp_path, monkeypatch):
|
||||||
|
coffre = create_coffre("personnel", "Personnel", "bruno")
|
||||||
|
secret = create_secret(coffre, "gitea-token", "token", VALEUR_TEST)
|
||||||
|
restaure = tmp_path / "restaure"
|
||||||
|
restaure.mkdir()
|
||||||
|
for nom in ("coffres.json", "coffres_valeurs.json"):
|
||||||
|
shutil.copy(env_coffre / nom, restaure / nom)
|
||||||
|
# Une autre KEK ne déchiffre rien : tag GCM invalide, pas de valeur.
|
||||||
|
monkeypatch.setenv("OBSIGATE_KEK", generate_kek_b64())
|
||||||
|
coffre_r = get_coffre_or_404("personnel", restaure)
|
||||||
|
with pytest.raises(TagInvalideError):
|
||||||
|
distribuer_valeur(coffre_r, secret, restaure)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 3 — Contexte Spark imbriqué (issue #21)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDistributionSpark:
|
||||||
|
def test_contexte_nested_mission(self, client):
|
||||||
|
_creer_coffre_secret(client)
|
||||||
|
_, jeton, _ = _creer_consommateur_octroi(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/coffres/distribuer",
|
||||||
|
json={
|
||||||
|
"reference": "coffre:personnel/gitea-token",
|
||||||
|
"contexte": {"type": "mission", "id": "mission-test-2"},
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {jeton}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["valeur"] == VALEUR_TEST
|
||||||
|
|
||||||
|
def test_contexte_nested_sans_octroi_acces_requis(self, client):
|
||||||
|
_creer_coffre_secret(client)
|
||||||
|
resp = client.post("/api/coffres/consommateurs", json={"nom": "spark"})
|
||||||
|
jeton = resp.json()["jeton"]
|
||||||
|
resp = client.post(
|
||||||
|
"/api/coffres/distribuer",
|
||||||
|
json={
|
||||||
|
"reference": "coffre:personnel/gitea-token",
|
||||||
|
"contexte": {"type": "mission", "id": "mission-test-3"},
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {jeton}"},
|
||||||
|
)
|
||||||
|
# Côté Spark, ce code se traduit par l'état acces_requis.
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert resp.json()["detail"] == "octroi_absent"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Phase 4 — Consommateur + octrois en un appel (issue #22)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestConsommateurAvecOctrois:
|
||||||
|
def test_avec_octrois_distribue(self, client, env_coffre):
|
||||||
|
_creer_coffre_secret(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/coffres/consommateurs/avec-octrois",
|
||||||
|
json={
|
||||||
|
"nom": "tiktak",
|
||||||
|
"octrois": [{"coffre_slug": "personnel", "secret_nom": "gitea-token"}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
corps = resp.json()
|
||||||
|
assert corps["nom"] == "tiktak"
|
||||||
|
assert corps["jeton"].startswith("csk_")
|
||||||
|
assert len(corps["octrois"]) == 1
|
||||||
|
assert corps["erreurs"] == []
|
||||||
|
resp = _distribuer(client, corps["jeton"])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["valeur"] == VALEUR_TEST
|
||||||
|
liste = client.get("/api/coffres/consommateurs").json()
|
||||||
|
assert corps["jeton"] not in json.dumps(liste)
|
||||||
|
assert "jeton_hash" not in json.dumps(liste)
|
||||||
|
|
||||||
|
def test_avec_octrois_erreur_signalee_sans_annuler(self, client):
|
||||||
|
_creer_coffre_secret(client)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/coffres/consommateurs/avec-octrois",
|
||||||
|
json={
|
||||||
|
"nom": "flowdeck",
|
||||||
|
"octrois": [
|
||||||
|
{"coffre_slug": "personnel", "secret_nom": "gitea-token"},
|
||||||
|
{"coffre_slug": "inexistant", "secret_nom": "gitea-token"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert len(resp.json()["octrois"]) == 1
|
||||||
|
assert len(resp.json()["erreurs"]) == 1
|
||||||
|
assert resp.json()["erreurs"][0]["coffre_slug"] == "inexistant"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Validates:
|
Validates:
|
||||||
- Theme JSON structure validity
|
- Theme JSON structure validity
|
||||||
- All 15 built-in themes have required keys and valid CSS colors
|
- All 16 built-in themes have required keys and valid CSS colors
|
||||||
- High-contrast and sepia mode generators produce valid CSS vars
|
- High-contrast and sepia mode generators produce valid CSS vars
|
||||||
- Import/export round-trip
|
- Import/export round-trip
|
||||||
- AVAILABLE_MODES includes all 4 modes
|
- AVAILABLE_MODES includes all 4 modes
|
||||||
@@ -80,9 +80,9 @@ class TestThemeStructure:
|
|||||||
assert '_genSepia' in self.content
|
assert '_genSepia' in self.content
|
||||||
|
|
||||||
def test_theme_count(self):
|
def test_theme_count(self):
|
||||||
"""Should have 15 built-in themes."""
|
"""Should have 16 built-in themes."""
|
||||||
names = _extract_theme_names(self.content)
|
names = _extract_theme_names(self.content)
|
||||||
assert len(names) == 15, f"Expected 15 themes, found {len(names)}: {names}"
|
assert len(names) == 16, f"Expected 16 themes, found {len(names)}: {names}"
|
||||||
|
|
||||||
def test_required_export_functions(self):
|
def test_required_export_functions(self):
|
||||||
"""All required functions should be exported."""
|
"""All required functions should be exported."""
|
||||||
|
|||||||
Reference in New Issue
Block a user