From f29a22886a8376f41ff6f20aaf5affbce431d471 Mon Sep 17 00:00:00 2001 From: Spark Date: Sun, 25 Oct 2026 18:10:58 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20URLs=20publiques=20de=20l'=C3=A9cosyst?= =?UTF-8?q?=C3=A8me=20par=20configuration=20=E2=80=94=20dev/prod=20sans=20?= =?UTF-8?q?noms=20en=20dur=20#223?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 25 +++++ CHANGELOG.md | 24 ++++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/agent_journal.py | 13 ++- backend/draco_config.py | 131 +++++++++++++++++++++++ backend/external_refs.py | 86 +++++++++++++-- backend/main.py | 42 +++++++- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 21 +++- docs/features/devprod-urls-config-223.md | 81 ++++++++++++++ frontend/draco-liens.js | 8 +- frontend/draco-panneau.js | 9 +- frontend/index.html | 10 ++ package.json | 2 +- tests/test_draco_barre.py | 21 +++- tests/test_draco_config.py | 115 ++++++++++++++++++++ tests/test_security_headers.py | 33 +++++- 21 files changed, 608 insertions(+), 33 deletions(-) create mode 100644 backend/draco_config.py create mode 100644 docs/features/devprod-urls-config-223.md create mode 100644 tests/test_draco_config.py diff --git a/.env.example b/.env.example index 575d788..afb7472 100644 --- a/.env.example +++ b/.env.example @@ -104,3 +104,28 @@ DEEPSEEK_MODEL=deepseek-chat # OBSIGATE_GITEA_URL=https://git.example.net # OBSIGATE_GITEA_TOKEN= # OBSIGATE_GITHUB_TOKEN= + +# ── Fusion / dev-prod — URL publiques de l'écosystème (#223) ── +# Aucun nom public n'est figé dans le code : ces variables portent les +# URL des autres applications. Valeurs par défaut (si absentes) = noms +# OFFICIELS de la production. L'environnement dev déclare ses noms en +# -dev dans son propre .env uniquement, par exemple : +# DRACO_TIKTAK_URL=https://tiktak-dev.dracodev.net +# DRACO_AGENT_URL=https://agent.dracodev.net +# DRACO_OBSIGATE_URL=https://og.dracodev.net +# DRACO_FLOWDECK_URL=https://flowdeck.dracodev.net +# DRACO_TIKTAK_URL=https://tiktak.dracodev.net +# DRACO_KANBANTE_URL=https://kanbante.dracodev.net +# DRACO_GITEA_URL=https://git.dracodev.net + +# ── Fusion — émission d'événements vers le service d'agents (#220) ── +# URL complète de réception ; à défaut, dérivée de DRACO_AGENT_URL. +# AGENT_EVENEMENTS_URL=https://agent.dracodev.net/api/v1/evenements +# AGENT_EVENEMENTS_CLE= +# AGENT_EVENEMENTS_DOMAINES={"NomDuVault": "famille"} + +# ── Fusion — émettrice SSO draco_session (#216/#217) ── +# DRACO_SESSION_SECRET= +# DRACO_SESSION_SECRET_PREV= +# DRACO_SESSION_COOKIE_DOMAIN=.dracodev.net +# DRACO_SSO_MACHINE_KEY= diff --git a/CHANGELOG.md b/CHANGELOG.md index df240a9..1a39875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.77.1**. +> [Unreleased](#unreleased). La dernière version livrée est **2.78.0**. --- @@ -14,6 +14,28 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.78.0] — 2026-10-25 + +### Modifié + +- **Dev/prod sans noms publics en dur (#223, Fusion)** — toutes les URL + publiques de l'écosystème dracodev viennent désormais de la + configuration (`backend/draco_config.py` : `DRACO_AGENT_URL`, + `DRACO_OBSIGATE_URL`, `DRACO_FLOWDECK_URL`, `DRACO_TIKTAK_URL`, + `DRACO_KANBANTE_URL`, `DRACO_GITEA_URL` ; défauts = noms officiels, + l'environnement dev déclare ses noms en `-dev` dans son seul `.env`). + La CSP (`connect-src`/`frame-src`) dérive l'origine de l'agent de la + config (à défaut, de l'origine d'`AGENT_EVENEMENTS_URL`) ; le backend + injecte `window.DRACO_URLS` dans `index.html` pour le panneau Spark, + la barre d'apps et les liens inter-apps ; les références croisées + reconnaissent les hôtes officiels et configurés et leur URL + d'affichage est reconstruite depuis la config à la lecture (aucune + donnée stockée migrée). WebAuthn confirmé dérivé de la requête / + `OBSIGATE_WEBAUTHN_*` (aucun changement). Redéploiement Docker + requis ; en dev, déclarer les noms en `-dev` dans le `.env` du poste. + +--- + ## [2.77.1] — 2026-10-24 ### Corrigé diff --git a/README.fr.md b/README.fr.md index 6afa12d..e74c2e9 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.77.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.78.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.77.1). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.78.0). --- -*Projet : ObsiGate | Version : 2.77.1 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.78.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 76e623a..9b8c9a4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.77.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.78.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.77.1). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.78.0). --- -*Project: ObsiGate | Version: 2.77.1 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.78.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 8416b36..63221e3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.77.1 +2.78.0 diff --git a/backend/agent_journal.py b/backend/agent_journal.py index 0c60bdc..2f7092f 100644 --- a/backend/agent_journal.py +++ b/backend/agent_journal.py @@ -55,6 +55,9 @@ import httpx logger = logging.getLogger("obsigate.agent_journal") SOURCE_APP = "obsigate" +# Défaut officiel, conservé pour référence : depuis #223, ``_url()`` +# passe par la config centralisée (``backend.draco_config``) — jamais +# un nom d'environnement particulier. URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements" DELAI_S = 2.0 @@ -72,7 +75,15 @@ def _cle() -> str: def _url() -> str: - return os.environ.get("AGENT_EVENEMENTS_URL", "").strip() or URL_DEFAUT + # #223 : AGENT_EVENEMENTS_URL d'abord ; à défaut, l'URL du service + # configurée (DRACO_AGENT_URL) complétée du chemin d'événements ; + # à défaut, le défaut officiel. + explicite = os.environ.get("AGENT_EVENEMENTS_URL", "").strip() + if explicite: + return explicite + from backend.draco_config import agent_url + + return f"{agent_url()}/api/v1/evenements" def actif() -> bool: diff --git a/backend/draco_config.py b/backend/draco_config.py new file mode 100644 index 0000000..2428a36 --- /dev/null +++ b/backend/draco_config.py @@ -0,0 +1,131 @@ +"""URLs publiques de l'écosystème dracodev — par configuration (#223). + +Règle dev/prod (Bruno, 2026-10-25) : **aucun nom public de +l'écosystème n'est figé dans le code vivant**. Chaque URL publique +vient de l'environnement ; les valeurs par défaut sont les noms +**officiels** (sans suffixe) de la future production. L'environnement +dev déclare ses noms en ``-dev`` dans son seul ``.env`` — le code +n'en connaît aucun. + +Variables (voir ``.env.example``) : + +- ``DRACO_AGENT_URL`` — base publique du service d'agents ; + à défaut, l'origine est **dérivée** de ``AGENT_EVENEMENTS_URL`` + (la configuration réelle de l'émission d'événements, #220), puis + le défaut officiel. +- ``DRACO_OBSIGATE_URL`` — base publique d'ObsiGate (liens profonds). +- ``DRACO_FLOWDECK_URL`` — base publique de Flowdeck. +- ``DRACO_TIKTAK_URL`` — base publique de TikTak. +- ``DRACO_KANBANTE_URL`` — base publique de Kanbante. +- ``DRACO_GITEA_URL`` — base publique de Gitea (les cartes + Kanbante *sont* des issues Gitea, contrat liens-profonds) ; + distincte de ``OBSIGATE_GITEA_URL`` (source connectée de + l'assistant, réglable depuis l'UI, pas une URL d'affichage). + +Ce module ne lit l'environnement qu'à l'appel (jamais à l'import) : +les tests et un changement de ``.env`` suivi d'un redémarrage +prennent effet sans état figé. +""" + +from __future__ import annotations + +import os +from urllib.parse import urlparse + +# Défauts officiels (production future) — jamais de nom en ``-dev`` ici. +DEFAUTS: dict[str, str] = { + "agent": "https://agent.dracodev.net", + "obsigate": "https://og.dracodev.net", + "flowdeck": "https://flowdeck.dracodev.net", + "tiktak": "https://tiktak.dracodev.net", + "kanbante": "https://kanbante.dracodev.net", + "gitea": "https://git.dracodev.net", +} + +_VARIABLES: dict[str, str] = { + "agent": "DRACO_AGENT_URL", + "obsigate": "DRACO_OBSIGATE_URL", + "flowdeck": "DRACO_FLOWDECK_URL", + "tiktak": "DRACO_TIKTAK_URL", + "kanbante": "DRACO_KANBANTE_URL", + "gitea": "DRACO_GITEA_URL", +} + + +def _nettoyer(valeur: str) -> str: + return valeur.strip().rstrip("/") + + +def origine(url: str) -> str: + """Origine ``schéma://hôte[:port]`` d'une URL (``""`` si illisible).""" + u = urlparse(url or "") + if u.scheme and u.netloc: + return f"{u.scheme}://{u.netloc}" + return "" + + +def agent_url() -> str: + """Base publique du service d'agents. + + Priorité : ``DRACO_AGENT_URL``, puis l'origine de + ``AGENT_EVENEMENTS_URL`` (déjà la configuration réelle du poste), + puis le défaut officiel. + """ + explicite = _nettoyer(os.environ.get(_VARIABLES["agent"], "")) + if explicite: + return explicite + evenements = _nettoyer(os.environ.get("AGENT_EVENEMENTS_URL", "")) + if evenements: + derivee = origine(evenements) + if derivee: + return derivee + return DEFAUTS["agent"] + + +def agent_origine() -> str: + """Origine du service d'agents, pour la CSP (``connect-src``/``frame-src``).""" + return origine(agent_url()) or DEFAUTS["agent"] + + +def app_url(app: str) -> str: + """Base publique d'une application de l'écosystème (sans ``/`` final).""" + if app == "agent": + return agent_url() + variable = _VARIABLES[app] + return _nettoyer(os.environ.get(variable, "")) or DEFAUTS[app] + + +def app_hote(app: str) -> str: + """Nom d'hôte (minuscules) de l'URL publique configurée de *app*.""" + return (urlparse(app_url(app)).hostname or "").lower() + + +def hotes_reconnus() -> dict[str, str]: + """``{hôte: app}`` pour le parseur de liens profonds. + + Contient **toujours** les hôtes officiels (un lien collé depuis la + production reste reconnaissable en dev, et réciproquement) **plus** + l'hôte configuré de chaque application (les noms en ``-dev`` du + poste quand ils sont déclarés dans son ``.env``). + """ + hotes: dict[str, str] = {} + for app, defaut in DEFAUTS.items(): + hote = (urlparse(defaut).hostname or "").lower() + if hote: + hotes[hote] = app + for app in DEFAUTS: + hote = app_hote(app) + if hote: + hotes[hote] = app + return hotes + + +def urls_publiques() -> dict[str, str]: + """Carte des URL publiques injectée à l'hôte web (``window.DRACO_URLS``). + + C'est le contrat d'injection de dracodev-commons : l'application + hôte (ici ObsiGate, qui sert ``index.html``) fournit les URL aux + assets partagés (panneau Spark, barre, liens) — les assets ne + portent que les défauts officiels en repli. + """ + return {app: app_url(app) for app in DEFAUTS} diff --git a/backend/external_refs.py b/backend/external_refs.py index 4b783e3..406b661 100644 --- a/backend/external_refs.py +++ b/backend/external_refs.py @@ -24,7 +24,7 @@ import uuid from datetime import UTC, datetime from pathlib import Path from typing import Any -from urllib.parse import parse_qs, unquote, urlparse +from urllib.parse import parse_qs, quote, unquote, urlparse logger = logging.getLogger("obsigate.external_refs") @@ -64,26 +64,87 @@ def _write_refs(username: str, refs: list[dict[str, Any]]) -> None: # ------------------------------------------------------------ parseur d'URL +def build_deep_link(target_app: str, target_type: str, target_id: str) -> str | None: + """Construit l'URL profonde d'affichage depuis la **configuration** + courante (#223), ou ``None`` si la forme est inconnue. + + Les références stockées portent une URL absolue figée au moment du + collage (voir ``add_ref``) : elle peut nommer l'autre environnement + (un lien collé en prod, lu en dev). L'affichage ne l'utilise donc + jamais telle quelle — il reconstruit l'URL depuis + ``(target_app, target_type, target_id)`` et les bases publiques + configurées (``backend.draco_config``), au moment de la lecture. + Aucune donnée stockée n'est migrée ni réécrite. + """ + from backend.draco_config import app_url + + if target_app == "tiktak": + if target_type == "task": + return f"{app_url('tiktak')}/?task={quote(target_id, safe='')}" + if target_type == "list": + return f"{app_url('tiktak')}/?list={quote(target_id, safe='')}" + return None + if target_app == "obsigate" and target_type == "note": + return f"{app_url('obsigate')}/#file={quote(target_id, safe=':')}" + if target_app == "flowdeck": + if target_type == "page": + return f"{app_url('flowdeck')}/pages/{quote(target_id, safe='')}" + if target_type == "project": + return f"{app_url('flowdeck')}/collections/{quote(target_id, safe='')}" + return None + if target_app == "kanbante" and target_type == "card": + m = re.fullmatch(r"([^/]+)/([^/#]+)#(\d+)", target_id or "") + if m: + owner, repo, num = m.groups() + return f"{app_url('gitea')}/{owner}/{repo}/issues/{num}" + return None + return None + + +def _avec_url_affichee(ref: dict[str, Any]) -> dict[str, Any]: + """Copie de la référence dont ``target_url`` est reconstruite + depuis la config courante (#223) — le stockage n'est pas touché.""" + construite = build_deep_link( + str(ref.get("target_app") or ""), + str(ref.get("target_type") or ""), + str(ref.get("target_id") or ""), + ) + if construite is None: + return ref + copie = dict(ref) + copie["target_url"] = construite + return copie + + def parse_deep_link(url: str) -> tuple[str, str, str, str] | None: """Reconnaît une URL profonde des applications dracodev. Formes figées par ``dracodev-commons/contrats/liens-profonds.md``. Retourne ``(target_app, target_type, target_id, label_par_défaut)`` ou ``None`` si l'URL ne correspond à aucune forme du contrat. + + Hôtes reconnus (#223) : les hôtes **officiels** et les hôtes + **configurés** de chaque application (``backend.draco_config``) — + un lien collé depuis l'environnement courant (noms en ``-dev`` + déclarés dans son ``.env``) comme depuis l'autre environnement + est donc reconnu ; aucun nom d'environnement n'est figé ici. """ + from backend.draco_config import hotes_reconnus + try: u = urlparse((url or "").strip()) except ValueError: return None host = (u.hostname or "").lower() - if host == "tiktak.dracodev.net": + app = hotes_reconnus().get(host) + if app == "tiktak": q = parse_qs(u.query) if q.get("task"): return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}") if q.get("list"): return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}") return None - if host == "og.dracodev.net": + if app == "obsigate": frag = u.fragment or "" if frag.startswith("file="): cible = unquote(frag[5:]) @@ -91,7 +152,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None: nom = cible.rsplit("/", 1)[-1] return ("obsigate", "note", cible, f"ObsiGate · {nom}") return None - if host == "flowdeck.dracodev.net": + if app == "flowdeck": m = re.fullmatch(r"/pages/(\d+)", u.path or "") if m: return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}") @@ -99,7 +160,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None: if m: return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}") return None - if host == "git.dracodev.net": + if app == "gitea": m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "") if m: owner, repo, num = m.groups() @@ -111,20 +172,25 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None: # ------------------------------------------------------------ CRUD def list_refs(username: str, vault: str, path: str) -> list[dict[str, Any]]: - """Références posées sur une note, dans l'ordre de création.""" + """Références posées sur une note, dans l'ordre de création. + + ``target_url`` de chaque référence retournée est **reconstruite + depuis la config courante** (#223, ``_avec_url_affichee``) : la + valeur stockée, figée au collage, n'est jamais servie telle quelle. + """ if not username: return [] return [ - r for r in _read_refs(username) + _avec_url_affichee(r) for r in _read_refs(username) if r.get("owner_vault") == vault and r.get("owner_path") == path ] def get_ref(username: str, ref_id: str) -> dict[str, Any] | None: - """Une référence par son identifiant, ou ``None``.""" + """Une référence par son identifiant, ou ``None`` (URL reconstruite, #223).""" for r in _read_refs(username): if r.get("id") == ref_id: - return r + return _avec_url_affichee(r) return None @@ -169,4 +235,4 @@ def delete_ref(username: str, ref_id: str) -> dict[str, Any] | None: return None removed = next(r for r in refs if r.get("id") == ref_id) _write_refs(username, kept) - return removed + return _avec_url_affichee(removed) diff --git a/backend/main.py b/backend/main.py index 6284174..3813f81 100644 --- a/backend/main.py +++ b/backend/main.py @@ -186,15 +186,21 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): # avec un nonce frais (`backend.csp`) ou servis par 'self'/CDN # listés s'exécutent. `style-src` garde 'unsafe-inline' (attributs # `style=` et `el.style` omniprésents — chantier séparé). + # #223 : l'origine du service d'agents n'est pas figée — elle + # est dérivée de la configuration (DRACO_AGENT_URL, sinon + # l'origine d'AGENT_EVENEMENTS_URL, sinon le défaut officiel). + from backend.draco_config import agent_origine + + agent = agent_origine() response.headers["Content-Security-Policy"] = ( "default-src 'self'; " f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; " "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; " "img-src 'self' data: blob:; " - "connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://agent.dracodev.net; " + f"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net {agent}; " "font-src 'self' data: https://fonts.gstatic.com https://esm.sh; " "worker-src 'self' blob:; " - "frame-src 'self' blob: https://agent.dracodev.net; " + f"frame-src 'self' blob: {agent}; " "object-src 'none'; " "base-uri 'self'; " "form-action 'self'; " @@ -829,6 +835,36 @@ def _html_with_nonce(request: Request, name: str) -> str: request.state.csp_nonce, ) + +def _index_html(request: Request) -> str: + """``index.html`` + injection des URL publiques de l'écosystème (#223). + + Contrat d'injection de dracodev-commons : l'hôte (ObsiGate, qui + sert la page) fournit les URL aux assets partagés — panneau Spark, + barre d'apps, liens inter-apps lisent ``window.DRACO_URLS`` et ne + portent que les défauts officiels en repli. Les valeurs viennent + de :mod:`backend.draco_config` (environnement ; défauts officiels, + noms en ``-dev`` déclarés par le seul ``.env`` du poste). Le script + injecté passe par le même mécanisme de nonce CSP que les autres + scripts inline de la page. + """ + import json as _json + + from backend.csp import inject_csp_nonce + from backend.draco_config import urls_publiques + + html = (FRONTEND_DIR / "index.html").read_text(encoding="utf-8") + script = ( + "" + ) + if "" in html: + html = html.replace("", script + "", 1) + else: # repli défensif : tout en tête de document + html = script + html + return inject_csp_nonce(html, request.state.csp_nonce) + if FRONTEND_DIR.exists(): # ``Cache-Control`` for /static is set by SecurityHeadersMiddleware (no-cache). app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static") @@ -907,5 +943,5 @@ if FRONTEND_DIR.exists(): """Serve the SPA index.html for all non-API routes.""" index_file = FRONTEND_DIR / "index.html" if index_file.exists(): - return HTMLResponse(content=_html_with_nonce(request, "index.html"), headers={"Cache-Control": "no-cache"}) + return HTMLResponse(content=_index_html(request), headers={"Cache-Control": "no-cache"}) raise HTTPException(status_code=404, detail="Frontend not found") diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 5ea755c..e434d8c 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.77.1" +version = "2.78.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 825825f..3c868cf 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.77.1" +version = "2.78.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index dcfdc4f..88c5f4e 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.77.1", + "version": "2.78.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c47ad09..153ea41 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.77.1 | **Dernière mise à jour :** 2026-10-24 +> **Version :** 2.78.0 | **Dernière mise à jour :** 2026-10-25 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -30,6 +30,25 @@ ## 🔵 En cours +### 223. Dev/prod sans noms publics en dur — URLs de l'écosystème par configuration + +- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-25 | **Statut :** ✅ livré le 2026-10-25 (version du hook — voir CHANGELOG ; redéploiement Docker requis, et en dev déclarer les noms en `-dev` dans le `.env` du poste) +- **Fiche :** [features/devprod-urls-config-223.md](./features/devprod-urls-config-223.md) +- **Description :** aucun nom public de l'écosystème dracodev ne doit être + figé dans le code vivant : les URL publiques (service d'agents, autres + apps) viennent de la configuration (défauts = noms **officiels** sans + suffixe ; l'environnement dev déclare ses noms en `-dev` dans son + `.env`). CSP dérivée de l'origine de l'agent configurée, panneau Spark + et liens inter-apps alimentés par l'hôte (injection `window.DRACO_URLS` + au service de `index.html`), références croisées reconstruites depuis + la config à l'affichage (aucune donnée migrée). +- **Sous-tâches :** + - [x] Module `backend/draco_config.py` + `.env.example` + - [x] CSP `connect-src`/`frame-src` dérivée de la config (tests via config) + - [x] `external_refs` : hôtes reconnus + URL d'affichage construites par config + - [x] Frontend : panneau Spark, liens TikTak, onglets des apps via `DRACO_URLS` + - [x] WebAuthn : confirmation rp_id/origines dérivés de la requête / `OBSIGATE_WEBAUTHN_*` + ### 222. Fusion — panneau Spark V3 : la console en surimpression pleine page - **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-23 | **Statut :** ✅ livré le 2026-10-23 (2.77.0, redéploiement Docker en attente) diff --git a/docs/features/devprod-urls-config-223.md b/docs/features/devprod-urls-config-223.md new file mode 100644 index 0000000..facc699 --- /dev/null +++ b/docs/features/devprod-urls-config-223.md @@ -0,0 +1,81 @@ +# #223 — Dev/prod sans noms publics en dur : URLs de l'écosystème par configuration + +> Ouvert le 2026-10-25 (chantier dev/prod de Bruno). Contexte : l'installation +> actuelle (`deepin.lab.home`) devient l'environnement **dev** (noms en `-dev`), +> la future installation (`jump.point.mint.home`) sera la **prod** (noms +> officiels sans suffixe), identique sauf noms et clés. Règle : aucun nom +> public de l'écosystème codé en dur dans le code vivant ; défauts = noms +> officiels ; le dev déclare ses `-dev` dans son seul `.env`. + +## Configuration (nouveau module `backend/draco_config.py`) + +Lecture à l'appel, jamais à l'import. Défauts officiels : + +| Variable | Défaut officiel | Rôle | +|---|---|---| +| `DRACO_AGENT_URL` | `https://agent.dracodev.net` | Base du service d'agents. À défaut : origine dérivée d'`AGENT_EVENEMENTS_URL`, puis défaut. | +| `DRACO_OBSIGATE_URL` | `https://og.dracodev.net` | Liens profonds ObsiGate | +| `DRACO_FLOWDECK_URL` | `https://flowdeck.dracodev.net` | Liens profonds Flowdeck | +| `DRACO_TIKTAK_URL` | `https://tiktak.dracodev.net` | Liens profonds TikTak | +| `DRACO_KANBANTE_URL` | `https://kanbante.dracodev.net` | Onglet Kanbante de la barre | +| `DRACO_GITEA_URL` | `https://git.dracodev.net` | Cartes Kanbante (= issues Gitea). Distincte d'`OBSIGATE_GITEA_URL` (source connectée de l'assistant). | + +`AGENT_EVENEMENTS_URL` (existant, #220) reste la configuration première du +journal d'événements ; `agent_journal._url()` retombe sur +`DRACO_AGENT_URL + /api/v1/evenements` quand elle est absente. + +## Backend + +- **CSP** (`backend/main.py`, `SecurityHeadersMiddleware`) : `connect-src` et + `frame-src` utilisent `draco_config.agent_origine()` — dérivée de la config + à chaque réponse, plus aucun littéral. Tests dans + `tests/test_security_headers.py` **via la config** (défaut officiel neutralisé, + dérivation depuis `AGENT_EVENEMENTS_URL`, priorité `DRACO_AGENT_URL`). +- **Références croisées** (`backend/external_refs.py`) : + - `parse_deep_link` reconnaît les hôtes officiels **et** configurés + (`draco_config.hotes_reconnus()`) — un lien collé depuis le dev comme + depuis la prod est reconnu. + - `build_deep_link(app, type, id)` construit l'URL d'affichage depuis la + config ; `list_refs` / `get_ref` / `delete_ref` servent des copies dont + `target_url` est **reconstruite à la lecture**. ⚠️ Les références + **stockées** (`data/external_refs/*.json`) gardent l'URL absolue collée + par l'utilisateur : aucune migration, la valeur stockée n'est simplement + plus jamais servie telle quelle. +- **WebAuthn — confirmé, aucun changement** : `resolve_relying_party()` + (`backend/auth/webauthn_mfa.py`, BUG-070) dérive `rp_id`/origines de la + requête (hôte exact, `X-Forwarded-Host`/`X-Forwarded-Proto` honorés si + `OBSIGATE_TRUST_PROXY=true`), et `OBSIGATE_WEBAUTHN_RP_ID` / + `OBSIGATE_WEBAUTHN_ORIGINS` priment quand ils sont posés. Aucun nom figé. + +## Frontend (contrat d'injection de dracodev-commons) + +L'hôte fournit les URL : `backend/main.py::_index_html()` injecte +`window.DRACO_URLS = {…}` (JSON issu de `draco_config.urls_publiques()`, +nonce CSP par le mécanisme existant) dans `index.html` au service. + +- `frontend/draco-panneau.js` : `API = window.DRACO_URLS.agent || défaut officiel` ; + `appHote()` retire un suffixe `-dev` éventuel du premier label d'hôte + (`og-dev` → `obsigate` pour le lien profond `?app=`). +- `frontend/draco-liens.js` : « Ouvrir dans TikTak » = + `window.DRACO_URLS.tiktak || défaut officiel`. +- `frontend/index.html` : les `href` des onglets portent les défauts officiels ; + le script de configuration de la barre les réécrit depuis `DRACO_URLS`. + Le menu des apps (`draco-barre.js`) est construit depuis ces onglets : il + suit donc sans changement. +- Chemin réellement servi : `/static/draco-*.js` via le montage `StaticFiles`, + `Cache-Control: no-cache` posé par `SecurityHeadersMiddleware` sur + `/static/*`, et service worker **network-first** pour le code — aucun + cache-busting par empreinte n'existe (revalidation systématique). + +## Restes assumés + +- Suffixe SSO `dracodev.net` (`draco_session.validate_return_to`, + `frontend/js/auth.js`) : ce n'est pas un nom d'hôte mais le domaine parent + commun aux deux environnements (les noms `-dev` restent des sous-domaines) ; + le domaine du cookie est déjà configurable (`DRACO_SESSION_COOKIE_DOMAIN`). +- Mentions textuelles (titres/messages du panneau, contact OpenAPI et lien + « À propos » vers le dépôt Git) : infra Git hors périmètre, ou libellés. +- `OBSIGATE_SEARXNG_URL` : déjà configurable, défaut officiel conservé. +- Le panneau reste un asset partagé : la même ligne d'injection doit être + recopiée dans les 3 autres apps par leurs chantiers respectifs pour + retrouver l'octet-identique. diff --git a/frontend/draco-liens.js b/frontend/draco-liens.js index 47e60f7..30f9652 100644 --- a/frontend/draco-liens.js +++ b/frontend/draco-liens.js @@ -23,6 +23,12 @@ kanbante: "#0d9488", }; + /* Base TikTak fournie par l'hôte via window.DRACO_URLS (contrat + * d'injection dracodev-commons, #223) ; repli = défaut officiel. */ + function baseTikTak() { + return (window.DRACO_URLS && window.DRACO_URLS.tiktak) || "https://tiktak.dracodev.net"; + } + function t(key, fallback) { try { var v = window.t && window.t(key); @@ -92,7 +98,7 @@ t("draco.links.projected", "Tâche TikTak projetée"))); var ouvrir = el("a", "btn-action draco-liens-ouvrir", t("draco.links.openInTikTak", "Ouvrir dans TikTak")); - ouvrir.href = "https://tiktak.dracodev.net/?task=" + tid[1]; + ouvrir.href = baseTikTak() + "/?task=" + tid[1]; ouvrir.target = "_blank"; ouvrir.rel = "noopener"; bandeau.appendChild(ouvrir); diff --git a/frontend/draco-panneau.js b/frontend/draco-panneau.js index 4f0d5df..7df5945 100644 --- a/frontend/draco-panneau.js +++ b/frontend/draco-panneau.js @@ -32,7 +32,10 @@ * configuré / exécuteur non configuré sont affichés tels quels. */ (function () { "use strict"; - var API = "https://agent.dracodev.net"; + /* URL du service : fournie par l'hôte via window.DRACO_URLS + * (contrat d'injection dracodev-commons, #223) — le repli est le + * défaut officiel ; aucun nom d'environnement n'est figé ici. */ + var API = (window.DRACO_URLS && window.DRACO_URLS.agent) || "https://agent.dracodev.net"; var convId = null; var convKind = null; // 'principale' | 'parallele' var convTitre = ""; @@ -56,7 +59,9 @@ /* ── Console web (même service, surimpression pleine page) ─── */ function appHote() { - var h = (window.location.hostname || "").split(".")[0]; + /* Premier label de l'hôte, suffixe d'environnement éventuel + * retiré (ex. « og-dev » → « og » en dev, #223). */ + var h = (window.location.hostname || "").split(".")[0].replace(/-dev$/, ""); var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck", kanbante: "kanbante", og: "obsigate" }; return correspondance[h] || null; diff --git a/frontend/index.html b/frontend/index.html index bcda216..ad2a463 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -397,6 +397,16 @@ localement ; la recherche focus la recherche native d'ObsiGate ; Alertes unifiées et Spark arrivent avec la fédération / l'agent commun (phases suivantes). */ + /* #223 : les href des onglets portent les défauts officiels ; + l'hôte les réécrit depuis window.DRACO_URLS (injecté par le + backend depuis sa configuration) quand il est présent. */ + (function () { + if (!window.DRACO_URLS) return; + document.querySelectorAll(".draco-apptab").forEach(function (a) { + var u = window.DRACO_URLS[a.dataset.app]; + if (u) a.href = u.replace(/\/$/, "") + "/"; + }); + })(); window.DRACO_BAR_CONFIG = { current: "obsigate", domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(), diff --git a/package.json b/package.json index c00fded..c26f1c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.77.1", + "version": "2.78.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_draco_barre.py b/tests/test_draco_barre.py index 281a6ac..5c60162 100644 --- a/tests/test_draco_barre.py +++ b/tests/test_draco_barre.py @@ -100,9 +100,28 @@ def test_panneau_console_surimpression_222(): def test_csp_ouvre_le_service_d_agents_218(): # Le panneau parle au service d'agents : la CSP doit nommer son # hôte en connect-src, sans ouvrir de générique supplémentaire. + # #223 : l'origine n'est plus un littéral dans main.py — elle est + # dérivée de la configuration (backend.draco_config.agent_origine, + # testée via la config dans tests/test_security_headers.py). main = (Path(__file__).resolve().parent.parent / "backend" / "main.py").read_text(encoding="utf-8") ligne = next(l for l in main.splitlines() if "connect-src" in l) - assert "https://agent.dracodev.net" in ligne + assert "agent_origine" in main + assert "{agent}" in ligne + + +def test_urls_ecosysteme_via_injection_hote_223(): + # #223 : panneau Spark, liens TikTak et onglets des apps lisent + # window.DRACO_URLS (injecté par l'hôte/backend depuis sa config) ; + # les littéraux restants ne sont que les défauts officiels en repli. + panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8") + assert "window.DRACO_URLS" in panneau + assert "DRACO_URLS.agent" in panneau + liens = (FRONTEND / "draco-liens.js").read_text(encoding="utf-8") + assert "window.DRACO_URLS" in liens + assert "baseTikTak()" in liens + html = _index() + assert "window.DRACO_URLS" in html + assert "DRACO_URLS[a.dataset.app]" in html def test_rail_mobile_vers_le_vrai_hamburger(): diff --git a/tests/test_draco_config.py b/tests/test_draco_config.py new file mode 100644 index 0000000..26f4bda --- /dev/null +++ b/tests/test_draco_config.py @@ -0,0 +1,115 @@ +"""URLs publiques de l'écosystème par configuration — #223 (dev/prod). + +Règle : aucun nom public de l'écosystème n'est figé dans le code +vivant. Défauts = noms **officiels** (production future) ; le dev +déclare ses noms en ``-dev`` dans son seul ``.env`` +(``DRACO_*_URL`` / ``AGENT_EVENEMENTS_URL``). On vérifie ici la +config centralisée, la dérivation de l'URL d'événements du journal, +la reconnaissance des liens profonds des deux environnements et la +reconstruction des URL d'affichage des références croisées. +""" + +import pytest + +from backend import draco_config + + +@pytest.fixture() +def env_propre(monkeypatch): + """Aucune variable d'URL d'écosystème posée (défauts officiels).""" + for nom in ( + "DRACO_AGENT_URL", "DRACO_OBSIGATE_URL", "DRACO_FLOWDECK_URL", + "DRACO_TIKTAK_URL", "DRACO_KANBANTE_URL", "DRACO_GITEA_URL", + "AGENT_EVENEMENTS_URL", + ): + monkeypatch.delenv(nom, raising=False) + return monkeypatch + + +def test_defauts_officiels_sans_dev(env_propre): + assert draco_config.agent_url() == "https://agent.dracodev.net" + assert draco_config.app_url("tiktak") == "https://tiktak.dracodev.net" + assert draco_config.app_url("obsigate") == "https://og.dracodev.net" + assert draco_config.app_url("flowdeck") == "https://flowdeck.dracodev.net" + assert draco_config.app_url("kanbante") == "https://kanbante.dracodev.net" + assert draco_config.app_url("gitea") == "https://git.dracodev.net" + for url in draco_config.urls_publiques().values(): + assert "-dev" not in url + + +def test_agent_derive_des_evenements(env_propre): + """Sans DRACO_AGENT_URL, l'origine suit AGENT_EVENEMENTS_URL — + la configuration réelle du poste (dev inclus).""" + env_propre.setenv( + "AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements") + assert draco_config.agent_url() == "https://agent-dev.dracodev.net" + assert draco_config.agent_origine() == "https://agent-dev.dracodev.net" + + +def test_agent_explicite_prime(env_propre): + env_propre.setenv( + "AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements") + env_propre.setenv("DRACO_AGENT_URL", "https://agent.example.net/") + assert draco_config.agent_url() == "https://agent.example.net" + + +def test_journal_url_suit_la_config(env_propre): + from backend import agent_journal + + assert agent_journal._url() == "https://agent.dracodev.net/api/v1/evenements" + env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net") + assert agent_journal._url() == "https://agent-dev.dracodev.net/api/v1/evenements" + env_propre.setenv( + "AGENT_EVENEMENTS_URL", "https://autre.example.net/api/v1/evenements") + assert agent_journal._url() == "https://autre.example.net/api/v1/evenements" + + +# ------------------------------------------------------------ liens profonds + +def test_parse_hotes_configures(env_propre): + """Un lien collé depuis l'environnement dev (hôtes déclarés dans + son .env) est reconnu, comme un lien officiel.""" + from backend.external_refs import parse_deep_link + + env_propre.setenv("DRACO_TIKTAK_URL", "https://tiktak-dev.dracodev.net") + env_propre.setenv("DRACO_OBSIGATE_URL", "https://og-dev.dracodev.net") + assert parse_deep_link("https://tiktak-dev.dracodev.net/?task=5") == ( + "tiktak", "task", "5", "TikTak · tâche 5") + assert parse_deep_link("https://og-dev.dracodev.net/#file=v:Dossier/Note.md") == ( + "obsigate", "note", "v:Dossier/Note.md", "ObsiGate · Note.md") + # L'officiel reste reconnu en parallèle (lien venu de la prod). + assert parse_deep_link("https://tiktak.dracodev.net/?task=5") is not None + + +def test_url_affichage_reconstruite_depuis_config(env_propre, tmp_path): + """#223 : la référence stockée garde son URL absolue d'origine + (aucune migration), mais l'URL **servie** est reconstruite depuis + la config courante.""" + from backend.external_refs import add_ref, build_deep_link, list_refs + + env_propre.setenv("OBSIGATE_EXTERNAL_REFS_DIR", str(tmp_path / "refs")) + assert build_deep_link("tiktak", "task", "42") == \ + "https://tiktak.dracodev.net/?task=42" + assert build_deep_link("kanbante", "card", "Projets/obsigate#7") == \ + "https://git.dracodev.net/Projets/obsigate/issues/7" + assert build_deep_link("obsigate", "note", "v:Dossier/Note.md") == \ + "https://og.dracodev.net/#file=v:Dossier%2FNote.md" + + env_propre.setenv("DRACO_TIKTAK_URL", "https://tiktak-dev.dracodev.net") + ref = add_ref("alice", "Vault", "N.md", "tiktak", "task", "42", + "https://tiktak.dracodev.net/?task=42", "TikTak · tâche 42") + assert ref["target_url"] == "https://tiktak.dracodev.net/?task=42" # stocké tel quel + servies = list_refs("alice", "Vault", "N.md") + assert servies[0]["target_url"] == "https://tiktak-dev.dracodev.net/?task=42" + + +# ------------------------------------------------------------ injection hôte + +def test_index_injecte_draco_urls(client, env_propre): + """L'hôte sert index.html avec window.DRACO_URLS issu de sa config.""" + env_propre.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net") + resp = client.get("/") + assert resp.status_code == 200 + assert "window.DRACO_URLS" in resp.text + assert '"agent": "https://agent-dev.dracodev.net"' in resp.text + assert '"tiktak": "https://tiktak.dracodev.net"' in resp.text diff --git a/tests/test_security_headers.py b/tests/test_security_headers.py index 4f42196..ef3502d 100644 --- a/tests/test_security_headers.py +++ b/tests/test_security_headers.py @@ -84,13 +84,19 @@ def _csp_directives(csp: str) -> dict[str, list[str]]: return directives -def test_csp_frame_src_allows_agent_console(client): +def test_csp_frame_src_allows_agent_console(client, monkeypatch): """BUG-119 : le « mode console » du panneau Spark charge la console du service d'agents (``https://agent.dracodev.net``) dans une iframe — ``frame-src`` doit autoriser cette origine, sinon le navigateur bloque le chargement (« This content is blocked »). Les sources historiques (``'self'`` et ``blob:``, visualiseur PDF) restent en place et aucune - autre origine n'est admise.""" + autre origine n'est admise. + + #223 : la configuration est neutralisée ici (défaut officiel) — + l'origine n'est plus figée dans le code, voir le test via config + ci-dessous.""" + monkeypatch.delenv("DRACO_AGENT_URL", raising=False) + monkeypatch.delenv("AGENT_EVENEMENTS_URL", raising=False) resp = client.get("/api/health") csp = resp.headers.get("content-security-policy", "") directives = _csp_directives(csp) @@ -99,3 +105,26 @@ def test_csp_frame_src_allows_agent_console(client): "blob:", "https://agent.dracodev.net", ] + assert "https://agent.dracodev.net" in directives.get("connect-src", []) + + +def test_csp_agent_origine_via_config(client, monkeypatch): + """#223 : ``frame-src``/``connect-src`` suivent l'origine de l'agent + **configurée** — dérivée d'``AGENT_EVENEMENTS_URL`` (config réelle + du poste, noms en ``-dev`` en dev), ou de ``DRACO_AGENT_URL`` qui + prime. Aucun nom d'environnement n'est codé en dur.""" + monkeypatch.delenv("DRACO_AGENT_URL", raising=False) + monkeypatch.setenv( + "AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements") + resp = client.get("/api/health") + directives = _csp_directives(resp.headers.get("content-security-policy", "")) + assert directives.get("frame-src") == [ + "'self'", "blob:", "https://agent-dev.dracodev.net"] + assert "https://agent-dev.dracodev.net" in directives.get("connect-src", []) + assert "https://agent.dracodev.net" not in directives.get("connect-src", []) + + monkeypatch.setenv("DRACO_AGENT_URL", "https://agent.example.net") + resp = client.get("/api/health") + directives = _csp_directives(resp.headers.get("content-security-policy", "")) + assert directives.get("frame-src") == [ + "'self'", "blob:", "https://agent.example.net"]