Compare commits

...
12 Commits
Author SHA1 Message Date
Spark 53ba3d1124 chore(release): 7.82.2 — CI test entièrement verte (pages-supports exclues des listes)
FlowDeck CI / docker (push) Successful in 1m43s
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 32m21s
VERSION 7.82.1 -> 7.82.2, littéral de synchronisation version="7.82.2" dans app/main.py, entrée CHANGELOG v7.82.2.
2026-10-25 22:38:53 +00:00
Spark 8ce3b4b421 test: réparer la fuite mock_http → og_fetcher et le marqueur page-templates
- mock_http (test_agent_web_tools) : og_fetcher, importé paresseusement
  par url_fetch pendant la fenêtre patchée, figeait la factory mockée
  'build' comme shared_client ; son handler répondait ensuite aux
  fetch_og_metadata des tests suivants du même worker xdist (flake
  test_v55 : « Titre de la page »). La restauration du finally couvre
  désormais og_fetcher, comme elle couvrait déjà url_fetch.
- test_editor_front_end_wired : depuis la refonte templates (fc85481),
  le câblage page-templates vit dans page_editor_scripts.js (le picker
  de base.html passe par le registre unifié /api/templates) ; le
  marqueur est vérifié dans le fichier qui le porte, à force égale.
2026-10-25 22:38:53 +00:00
Spark 193fbd14ce fix(listes): exclure les pages-supports de templates des listes utilisateur
Les pages-supports créées par TemplateService._create_holder (une par
template, search_excluded=1, « masquées, jamais listées ») n'étaient
exclues d'aucune liste : semées à l'init avec updated_at = now (99 sur
une base fraîche), elles occupaient toutes les places des listes
triées par récence (LIMIT 50/20/8) devant les vraies pages — cause
racine de 7 échecs CI (library recents/private/workspace, sidebar
recents, projets builtin, mentions agent), dont l'instabilité du test
provenance (égalité de seconde avec le seed).

Filtre COALESCE(search_excluded, 0) = 0 ajouté : onglets bibliothèque
(meetings, recents, favorites, shared, published, private, workspace),
sidebar Recents, projets builtin, scans de pages des mentions agent.
2026-10-25 22:38:53 +00:00
Spark 3c8fc3070a chore(release): 7.82.1 — CI verte (lint + test de version)
FlowDeck CI / test (push) Failing after 33m52s
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Skipped
VERSION 7.82.0 -> 7.82.1, littéral de synchronisation version="7.82.1" dans app/main.py, entrée CHANGELOG v7.82.1.
2026-10-25 19:26:59 +00:00
Spark 49164ce057 fix(ci): corriger les 16 erreurs ruff et resynchroniser le littéral de version
Lint (run 2304, job 7058, ruff check app tests) : E402+I001 import mcp_server remonté en tête de app/main.py ; B904 x8 dans api_v2/templates_io.py (raise ... from None, from exc pour ValueError) ; UP017 datetime.UTC dans agent_evenements.py ; F841 x3 dans fd_templates.py (void/uid morts, import inutilisé retirés en chaîne) ; F401+F841 dans tests/test_fd_templates.py (import json inutilisé, n_presets en appel nu).

Version : le test test_pwa_offline cherche le littéral version="7.82.0" dans app/main.py — patron historique des bumps (scripts/bump_version.py le cherche aussi), rompu en 7.80.2 quand l'argument FastAPI est passé à _lire_version(). Littéral reposé en commentaire de synchronisation explicite, version runtime inchangée (lecture dynamique VERSION).
2026-10-25 19:26:09 +00:00
Spark f8449409b2 refactor: noms publics dracodev par configuration (dev/prod)
FlowDeck CI / test (push) Failing after 35m34s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 1m41s
Aucun nom public d'une autre app dracodev n'est codé en dur dans le code vivant : nouveau module app/services/draco_urls.py (env > config > défaut officiel, jamais de -dev en défaut). CSP connect-src/frame-src, bouton SSO ObsiGate, révocations SSO, événements agent, preset MCP TikTak, barre commune, panneau Spark (injection hôte DRACO_PANNEAU_CONFIG), refs croisées (hôte configuré + officiel reconnus, URL d'affichage reconstruite, aucune donnée stockée migrée), User-Agent OG, liens Gitea. Variables ajoutées DRACO_AGENT_URL/TIKTAK_URL/KANBANTE_URL/FLOWDECK_URL, réutilisées DRACO_SSO_URL/OAUTH_REDIRECT_URI/AGENT_EVENEMENTS_URL/GITEA_URL/APP_BASE_URL. Version 7.82.0.
2026-10-25 18:19:30 +00:00
Spark eb4c42b0b5 7.81.0 — Panneau Spark V3 : la console en surimpression pleine page (panneau octet-identique à TikTak 0.60.0)
FlowDeck CI / lint (push) Failing after 1m31s
FlowDeck CI / test (push) Failing after 32m14s
FlowDeck CI / docker (push) Skipped
2026-10-23 04:06:11 +00:00
Spark 73b3fbce6e fix(version): /api/health lit la version depuis le fichier VERSION — fin de la constante 7.74.3 périmée (7.80.2)
FlowDeck CI / test (push) Failing after 29m53s
FlowDeck CI / lint (push) Failing after 1m30s
FlowDeck CI / docker (push) Skipped
2026-10-22 17:41:24 -04:00
Spark c7da7002ca Panneau Spark : bouton « Console ↗ » vers la console du service (7.80.1)
FlowDeck CI / test (push) Failing after 30m58s
FlowDeck CI / lint (push) Failing after 1m31s
FlowDeck CI / docker (push) Skipped
2026-10-22 21:17:39 +00:00
Spark 851a7b17d2 7.80.0 — Émission d'événements vers le journal du service d'agents (Lot 2)
FlowDeck CI / lint (push) Failing after 1m29s
FlowDeck CI / test (push) Failing after 29m9s
FlowDeck CI / docker (push) Skipped
2026-10-22 20:25:36 +00:00
Spark e459b0f9b2 7.79.0 — Panneau Spark v2 (Lot 1)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- draco-panneau vendangé v2 (identique dans les 4 apps) : Markdown
  rendu par le service, largeur réglable, thème aligné, discussions
  façon Muse (principale + parallèles)
- 25 tests du périmètre verts
2026-10-22 11:50:29 -04:00
Spark 133156580c 7.78.0 — MCP : périmètre du propriétaire du jeton
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
list_workspaces/get_workspace filtrés par user_workspace_ids ;
collections filtrées par workspace effectif ; hors périmètre =
introuvable, lectures et écritures. Test deux comptes.
2026-10-22 09:22:19 -04:00
44 changed files with 1965 additions and 171 deletions
+21
View File
@@ -20,6 +20,27 @@ GITHUB_OAUTH_CLIENT_SECRET=
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback # Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
OAUTH_REDIRECT_URI= OAUTH_REDIRECT_URI=
# ── URLs publiques dracodev — dev/prod (v7.82.0) ──
# AUCUN nom public n'est codé en dur dans le code vivant : tout passe
# par ces variables (résolution : env > config > défaut). Les DÉFAUTS
# sont les noms OFFICIELS (production, sans suffixe). Un environnement
# DEV déclare ses noms en -dev ici — le .env survit aux déploiements
# par release (passé par environment:/env_file: au conteneur).
# Laisser vide = défaut officiel indiqué en commentaire.
DRACO_SSO_URL= # ObsiGate, émettrice SSO — défaut https://og.dracodev.net ; dev : https://og-dev.dracodev.net
DRACO_AGENT_URL= # Service d'agents Spark — défaut https://agent.dracodev.net ; dev : https://agent-dev.dracodev.net
TIKTAK_URL= # TikTak (preset MCP, barre, refs) — défaut https://tiktak.dracodev.net ; dev : https://tiktak-dev.dracodev.net
KANBANTE_URL= # Kanbante (barre, refs) — défaut https://kanbante.dracodev.net ; dev : https://kanbante-dev.dracodev.net
FLOWDECK_URL= # URL publique de CETTE instance — défaut : dérivée d'OAUTH_REDIRECT_URI, sinon https://flowdeck.dracodev.net ; dev : https://flowdeck-dev.dracodev.net
# SSO draco (Fusion ACC-3.2) — secret partagé avec l'émettrice ObsiGate.
DRACO_SESSION_SECRET=
DRACO_SESSION_SECRET_PREV=
DRACO_SSO_MACHINE_KEY=
# Événements vers le journal du service d'agents (Fusion, Lot 2).
# AGENT_EVENEMENTS_URL vide = dérivée de DRACO_AGENT_URL (+ /api/v1/evenements).
AGENT_EVENEMENTS_URL=
AGENT_EVENEMENTS_CLE=
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ── # ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
# Vidés = connecteur « non configuré » (le menu + affiche la raison). # Vidés = connecteur « non configuré » (le menu + affiche la raison).
# Le redirect URI à enregistrer est dérivé d'APP_BASE_URL : # Le redirect URI à enregistrer est dérivé d'APP_BASE_URL :
+194
View File
@@ -1,5 +1,199 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.82.2 (2026-10-25) — CI test entièrement verte : pages-supports de templates exclues des listes
Cause racine commune à 7 des 8 échecs restants : la refonte templates
(`fc85481`, 2026-10-10) a introduit les **pages-supports** — une page
interne par template système (99 sur une base fraîche), créée par
`TemplateService._create_holder` avec `search_excluded=1` et la consigne
« masquée, jamais listée ». Aucune liste ne respectait cette consigne :
datées `updated_at = now`, elles occupaient toutes les places des
listes triées par récence (LIMIT 50/20/8), devant les vraies pages.
- **Bibliothèque** (`app/routers/library.py`) : filtre `NO_HOLDER`
(`COALESCE(p.search_excluded, 0) = 0`) ajouté aux onglets meetings,
recents, favorites, shared, published, private et workspace.
- **Sidebar Recents** (`dashboard/_common.py::_recent_sidebar_pages`)
et **projets builtin** (`dashboard/workspace.py`) : même exclusion —
les pages-supports n'y sont plus ni des « fichiers récents » ni des
« projets ».
- **Mentions agent** (`routers/agent.py::list_mentions`) : les trois
scans de pages excluent les pages-supports, jamais mentionnables.
- **Flake `test_v55` (og parser)** : `mock_http`
(`tests/test_agent_web_tools.py`) répare désormais aussi la référence
`shared_client` figée par `og_fetcher` quand il est importé pendant
la fenêtre patchée (même classe de fuite que celle déjà traitée pour
`url_fetch`) — le handler d'un test `fetch_url` répondait sinon aux
fetchs OG des tests suivants du même worker xdist.
- **`test_editor_front_end_wired`** : depuis `fc85481`, le picker de
`base.html` passe par le registre unifié (`/api/templates`) et le
câblage `page-templates` vit dans l'éditeur
(`static/js/page_editor_scripts.js`) ; le marqueur est vérifié dans
le fichier qui le porte. Aucune assertion supprimée ni affaiblie.
## v7.82.1 (2026-10-25) — CI : lint ruff vert et littéral de version resynchronisé
- **Lint (CI run 2304, job 7058)** : les 16 erreurs `ruff check
app tests` sont corrigées dans le code, sans toucher à la
configuration ruff ni ajouter de `noqa` : import du routeur MCP
remonté en tête de `app/main.py` (E402/I001) ; `raise … from
None` / `from exc` sur les 8 `raise` dans des `except` de
`app/routers/api_v2/templates_io.py` (B904) ; `datetime.UTC`
dans `app/services/agent_evenements.py` (UP017) ; variables
mortes `void`/`uid` et import devenu inutilisé retirés de
`app/services/fd_templates.py` (F841) ; import `json`
inutilisé et variable `n_presets` morte retirés de
`tests/test_fd_templates.py` (F401/F841).
- **Test de version** : `test_pwa_offline` cherche le littéral
`version="…"` dans `app/main.py` — patron historique des bumps
(`scripts/bump_version.py` le cherche aussi), rompu en 7.80.2
quand l'argument FastAPI est passé à la lecture dynamique de
`VERSION`. Le littéral est reposé en commentaire de
synchronisation explicite (`version="7.82.1"`), à mettre à
jour à chaque bump ; la version runtime reste lue
dynamiquement depuis `VERSION` par `_lire_version()`.
- Aucun autre comportement modifié. Huit échecs de tests
pré-existants (antérieurs à la 7.82.0, prouvés par comparaison
des runs ciblés sur `eb4c42b` et `f844940`) restent ouverts et
ne sont pas corrigés par cette version.
## v7.82.0 (2026-10-25) — Noms publics dracodev par configuration (dev/prod)
- **Aucun nom public d'une autre application n'est plus codé en dur**
dans le code vivant : nouveau module `app/services/draco_urls.py`,
source unique qui résout chaque URL depuis l'environnement
(env > `app/config.py` > défaut). Les **défauts sont les noms
officiels** (production) ; un environnement dev déclare ses noms
en `-dev` dans son `.env` (qui survit aux déploiements par release).
Variables **ajoutées** : `DRACO_AGENT_URL`, `TIKTAK_URL`,
`KANBANTE_URL`, `FLOWDECK_URL`, `AGENT_EVENEMENTS_URL` (documentée).
Variables **réutilisées** : `DRACO_SSO_URL` (ObsiGate/SSO, déjà en
service), `OAUTH_REDIRECT_URI` (son origine sert d'URL publique à
l'instance si `FLOWDECK_URL` est vide), `AGENT_EVENEMENTS_URL` +
`AGENT_EVENEMENTS_CLE`, `GITEA_URL`, `APP_BASE_URL`.
- **Surfaces corrigées** : CSP (`connect-src`/`frame-src` du service
d'agents dérivés de `DRACO_AGENT_URL`) ; bouton « Login with
ObsiGate (SSO) » de la page de connexion (émettrice + retour
dérivés de la config) ; émission d'événements (endpoint dérivé de
la base de l'agent si `AGENT_EVENEMENTS_URL` est vide) ; révocations
SSO (`DRACO_SSO_URL`) ; preset MCP TikTak des connecteurs et du
panneau agent (URL injectée par l'hôte) ; User-Agent du fetcher OG
(URL publique de l'instance) ; barre commune `_draco_bar.html`
(href injectés par l'hôte via `draco_urls()`) ; panneau Spark
vendangé (contrat d'injection `window.DRACO_PANNEAU_CONFIG.agent_url`
rendu par `base.html`, repli = nom officiel seulement ; déduction
de l'app hôte insensible au suffixe `-dev`) ; liens Gitea du
dashboard et des réglages (`GITEA_URL`).
- **Références croisées** : le parseur de liens profonds reconnaît
l'hôte **configuré** de chaque app **et** l'hôte officiel, et l'URL
d'affichage est reconstruite avec l'hôte configuré (chemin, requête
et fragment intacts). **Aucune donnée migrée** : `target_url` reste
stockée telle que collée par l'utilisateur ; seule l'affichage est
recalculée.
- **Extension Web Clipper** : les manifests autorisent aussi
l'hôte dev (`flowdeck-dev.dracodev.net`) à côté de l'officiel.
- Tests : nouvelle suite `tests/test_draco_urls_devprod.py`
(défauts officiels sans `-dev`, surcharges dev, CSP, liens profonds
dev + officiels, URL d'affichage, preset TikTak, bouton SSO).
## v7.81.0 (2026-10-22) — Panneau Spark V3 : la console en surimpression pleine page
- **Fini le nouvel onglet** (demande de Bruno) : le bouton
« Console ↗ » du panneau Spark devient « Console ⤢ » et ouvre
la console du service d'agents **dans la même page**, en
surimpression qui naît à la géométrie du panneau (ancrée à
droite, sa largeur) puis **s'étend en ~300 ms jusqu'à recouvrir
toute la fenêtre**. L'URL profonde est inchangée
(`?disc=<discussion affichée>`, `?app=flowdeck`).
- **Barre d'outils de la surimpression** : « ◧ Mode sidebar »
(animation inverse, retour au panneau latéral resté ouvert
dessous) et « ✕ Fermer l'agent » (referme surimpression **et**
panneau). Échap hors iframe = « Mode sidebar ».
- **Iframe créé paresseusement et conservé** : chargé au premier
clic, puis masqué/réaffiché sans rechargement — l'état de la
console persiste entre ouvertures. `allow="microphone"` pour
la dictée encadrée (aucune Permissions-Policy de Flowdeck ne
bloque le micro, vérifié) ; côté service, la 0.20.0 autorise
l'encadrement par les seules origines des 4 applications
(CSP `frame-ancestors`).
- La surimpression vit entièrement dans le fichier panneau
(DOM + styles injectés, thème synchronisé) ; seul
`static/js/draco-panneau.js` change ici, **octet-identique**
au canonique TikTak 0.60.0 (md5 commun vérifié). Kanbante et
ObsiGate suivront en vague 2 du panneau V3.
## v7.80.2 (2026-10-22) — /api/health : version lue depuis le fichier VERSION
- La version de l'app FastAPI était une constante codée en dur
dans `app/main.py` (restée à **7.74.3**) : `/api/health`
affichait donc une version périmée alors que l'app déployée
était en 7.80.1 (signalé par Bruno). Elle est maintenant lue
depuis le fichier `VERSION` à la racine (source unique, même
repli `/app/VERSION` que le tableau de bord) — elle ne pourra
plus dériver.
## v7.80.1 (2026-10-22) — Panneau Spark : bouton « Console ↗ »
- **Bouton « Console ↗ »** dans la barre des discussions du
panneau Spark (demande de Bruno) : ouvre la console web du
service d'agents (https://agent.dracodev.net/) dans un nouvel
onglet, sur la discussion actuellement affichée (`?disc=<id>`,
URL construite au clic) et avec l'app d'origine
(`?app=flowdeck`, déduite du sous-domaine). Panneau recopié
octet-identique dans les 4 apps (TikTak 0.59.1, Flowdeck
7.80.1, Kanbante 0.28.1, ObsiGate 2.76.1).
## v7.80.0 (2026-10-22) — Émission d'événements vers le journal du service d'agents (Lot 2)
- **Nouveau module `app/services/agent_evenements.py`** : Flowdeck
émet ses mutations significatives vers le journal d'activité du
service d'agents (`POST /api/v1/evenements`, contrat ACC-0.2) :
enveloppe {id, type, occurred_at, actor, domain, source_app,
object{ref, label, url}} — une référence et un libellé, jamais le
contenu — signée **HMAC-SHA256** (en-tête `X-Draco-Signature`)
avec la clé propre à Flowdeck. Envoi en fil d'arrière-plan, délai
maximal 2 s, erreurs avalées : **inactif tant que
`AGENT_EVENEMENTS_CLE` n'est pas posée** dans l'environnement, et
une panne du service ne casse ni ne ralentit jamais le métier.
- **Événements émis** : `flowdeck.page.created` /
`flowdeck.page.updated` / `flowdeck.page.deleted` et
`flowdeck.collection.created`, branchés sur les surfaces réelles
de mutation : API de session (pages du board, pages et
collections des bases), API publique v2 (acteur « user ») et
outils MCP `create_page` / `update_page` / `create_collection`
(acteur « agent:mcp », dans le périmètre du jeton). Références
`page:<id>` / `collection:<id>`, liens profonds vers la page
(`/board/pages/<id>`) ou la vue de sa collection.
- **Aucun événement meeting** : la décision A/B sur le bloc
meeting reste ouverte, le sujet est explicitement exclu.
## v7.79.0 (2026-10-22) — Panneau Spark v2 (Lot 1)
- **Panneau Spark** (`draco-panneau.js/css`, vendangés dans les 4
apps) : réponses de l'agent en **Markdown rendu** (HTML du
service d'agents 0.10.0, sanitizé côté serveur), rapports de
missions compris ; **largeur réglable** persistée ; **thème
clair/sombre aligné** sur le thème de Flowdeck
(`html[data-theme]`, suivi en direct) ; **discussions façon
Muse** : discussion principale perpétuelle + parallèles
créées/archivées/supprimées depuis le tiroir du panneau.
## v7.78.0 (2026-10-22) — MCP : périmètre du jeton (enfin)
### Corrigé
- **`list_workspaces` rendait les 52 workspaces de la base** — tous
les comptes et les résidus de tests : le MCP vérifiait le jeton
(qui tu es) mais n'appliquait aucun périmètre (ce que ce compte a
le droit de voir). Cas réel de Bruno, quelques heures après la
7.77.0. Désormais : workspaces du propriétaire du jeton
(propriétaire ou membre, via `user_workspace_ids`), collections
filtrées par leur workspace effectif (direct ou via la page hôte ;
les collections sans rattachement restent visibles, comme dans
l'API publique historique), et tout objet hors périmètre répond
« introuvable » — lectures **et** écritures. Test de régression
à deux comptes.
## v7.77.0 (2026-10-22) — Corbeille réparée + MCP : les workspaces ## v7.77.0 (2026-10-22) — Corbeille réparée + MCP : les workspaces
### Corrigé ### Corrigé
+19
View File
@@ -86,6 +86,25 @@ APP_PORT=8080
DATABASE_URL=sqlite:////data/flowdeck.db DATABASE_URL=sqlite:////data/flowdeck.db
``` ```
### Environnements dev/prod (v7.82.0)
Aucun nom public d'une application dracodev n'est codé en dur : les
URL des autres apps (ObsiGate/SSO, service d'agents, TikTak, Kanbante)
et l'URL publique de Flowdeck se résolvent depuis le `.env` (voir la
section « URLs publiques dracodev » de `.env.example` et
`app/services/draco_urls.py`). Les valeurs par défaut sont les noms
**officiels** (production) ; un environnement de développement déclare
les noms en `-dev` dans son `.env`, par exemple
`DRACO_SSO_URL=https://og-dev.dracodev.net`,
`DRACO_AGENT_URL=https://agent-dev.dracodev.net`,
`TIKTAK_URL=https://tiktak-dev.dracodev.net`,
`KANBANTE_URL=https://kanbante-dev.dracodev.net` et
`OAUTH_REDIRECT_URI=https://flowdeck-dev.dracodev.net/auth/callback`
(dont l'origine sert d'URL publique à l'instance, sauf si
`FLOWDECK_URL` est posée). La CSP, la barre commune, le panneau Spark,
le bouton SSO, le preset MCP TikTak et les références croisées
suivent automatiquement.
## Tests ## Tests
```bash ```bash
+1 -1
View File
@@ -1 +1 @@
7.77.0 7.82.2
+22
View File
@@ -147,6 +147,28 @@ class Settings(BaseSettings):
github_token: str = "" # PAT GitHub : 30 req/min au lieu de 10 github_token: str = "" # PAT GitHub : 30 req/min au lieu de 10
web_fetch_max_chars: int = 20000 # texte renvoyé par fetch_url web_fetch_max_chars: int = 20000 # texte renvoyé par fetch_url
# ── URLs publiques dracodev (dev/prod, v7.82.0) ──
# AUCUN nom public d'une autre app n'est codé en dur dans le code
# vivant : chaque URL se résout ici (voir app/services/draco_urls.py).
# Les défauts sont les noms OFFICIELS (production) ; un environnement
# dev déclare les noms en `-dev` dans son .env, par exemple :
# DRACO_SSO_URL=https://og-dev.dracodev.net
# DRACO_AGENT_URL=https://agent-dev.dracodev.net
# TIKTAK_URL=https://tiktak-dev.dracodev.net
# KANBANTE_URL=https://kanbante-dev.dracodev.net
# FLOWDECK_URL=https://flowdeck-dev.dracodev.net
draco_sso_url: str = "https://og.dracodev.net" # ObsiGate (émettrice SSO)
draco_agent_url: str = "https://agent.dracodev.net" # service d'agents (Spark)
tiktak_url: str = "https://tiktak.dracodev.net"
kanbante_url: str = "https://kanbante.dracodev.net"
# URL publique de CETTE instance ; vide = dérivée de
# OAUTH_REDIRECT_URI, puis d'APP_BASE_URL (si non locale), puis du
# nom officiel.
flowdeck_url: str = ""
# AGENT_EVENEMENTS_URL (déjà en service) : vide = dérivée de
# DRACO_AGENT_URL + /api/v1/evenements.
agent_evenements_url: str = ""
# ── Vues Notion : géocodage + plafonds (§8.2, §13.4, §20) ── # ── Vues Notion : géocodage + plafonds (§8.2, §13.4, §20) ──
GEOCODER_PROVIDER: str = "none" # none | nominatim | custom GEOCODER_PROVIDER: str = "none" # none | nominatim | custom
GEOCODER_ENDPOINT: str = "https://nominatim.openstreetmap.org" GEOCODER_ENDPOINT: str = "https://nominatim.openstreetmap.org"
+33 -3
View File
@@ -51,6 +51,7 @@ from app.routers.github_routes import router as github_router
from app.routers.governance import router as governance_router from app.routers.governance import router as governance_router
from app.routers.imports import page_router as import_page_router from app.routers.imports import page_router as import_page_router
from app.routers.imports import router as imports_router from app.routers.imports import router as imports_router
from app.routers.mcp_server import router as mcp_server_router
from app.routers.meetings import router as meetings_router from app.routers.meetings import router as meetings_router
from app.routers.notifications import router as notifications_router from app.routers.notifications import router as notifications_router
from app.routers.permissions import router as permissions_router from app.routers.permissions import router as permissions_router
@@ -190,9 +191,39 @@ async def lifespan(_app: FastAPI):
pass pass
def _lire_version() -> str:
"""Version de l'app depuis le fichier VERSION (source unique).
Avant : une constante codée en dur dans FastAPI(...) — restée à
7.74.3 alors que l'app était en 7.80.1, donc /api/health mentait.
Même logique de repli que dashboard._common._get_app_version.
"""
import os
for chemin in (
os.path.join(os.path.dirname(__file__), "..", "VERSION"),
"/app/VERSION",
):
try:
if os.path.exists(chemin):
with open(chemin) as f:
v = f.read().strip()
if v:
return v
except Exception:
pass
return "0.0.0"
# Littéral de version synchronisé avec le fichier VERSION :
# version="7.82.2"
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
# (correctif 7.80.2) — ce littéral n'est qu'un repère de synchronisation.
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version="7.74.3", version=_lire_version(),
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
lifespan=lifespan, lifespan=lifespan,
@@ -239,8 +270,7 @@ app.include_router(admin.router)
app.include_router(gitea_router) app.include_router(gitea_router)
app.include_router(github_router) app.include_router(github_router)
app.include_router(public_api.router) app.include_router(public_api.router)
from app.routers import mcp_server as _mcp_server # Fusion — serveur MCP (service d'agents) app.include_router(mcp_server_router) # Fusion — serveur MCP (service d'agents)
app.include_router(_mcp_server.router)
app.include_router(sharing.router) app.include_router(sharing.router)
app.include_router(sidebar_config.router) app.include_router(sidebar_config.router)
app.include_router(export.router) app.include_router(export.router)
+12 -3
View File
@@ -133,9 +133,14 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# Exception Fusion (2026-10-21) : le panneau Spark parle au # Exception Fusion (2026-10-21) : le panneau Spark parle au
# service d'agents commun (premier parti, même programme) — # service d'agents commun (premier parti, même programme) —
# seul hôte externe nommé, rien d'autre ne s'ouvre. # seul hôte externe nommé, rien d'autre ne s'ouvre.
"connect-src 'self' https://agent.dracodev.net ws://{host} wss://{host}; " # v7.82.0 (dev/prod) : cet hôte n'est plus figé — {agent} est
# l'origine résolue par la config (DRACO_AGENT_URL ; défaut =
# nom officiel, un dev déclare son agent-dev dans le .env).
# Même origine pour frame-src : la console du service est
# encadrée en iframe par le panneau (surimpression v3).
"connect-src 'self' {agent} ws://{host} wss://{host}; "
"media-src 'self' blob:; " "media-src 'self' blob:; "
"frame-src 'self'; " "frame-src 'self' {agent}; "
"object-src 'none'; " "object-src 'none'; "
"base-uri 'self'; " "base-uri 'self'; "
"form-action 'self'; " "form-action 'self'; "
@@ -155,8 +160,12 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# on retire quand même tout caractère hors base URL par sécurité. # on retire quand même tout caractère hors base URL par sécurité.
host = re.sub(r"[^0-9A-Za-z.\-:\[\]]", "", host = re.sub(r"[^0-9A-Za-z.\-:\[\]]", "",
request.headers.get("host", "")) request.headers.get("host", ""))
from app.services import draco_urls
agent = re.sub(r"[^0-9A-Za-z.\-:/]", "",
draco_urls.origine(draco_urls.agent_url()))
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format( response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
nonce=nonce, host=host nonce=nonce, host=host, agent=agent
) )
return response return response
+5 -3
View File
@@ -838,6 +838,8 @@ def list_mentions(request: Request, q: str = ""):
Les entrées identiques (même type + même titre, ex. plusieurs pages « Sans Les entrées identiques (même type + même titre, ex. plusieurs pages « Sans
titre ») sont **dédupliquées** pour ne pas afficher le même fichier plusieurs fois. titre ») sont **dédupliquées** pour ne pas afficher le même fichier plusieurs fois.
""" """
# Les scans de pages excluent les pages-supports de templates
# (search_excluded=1 — cf. library.NO_HOLDER) : jamais mentionnables.
query = (q or "").strip()[:60] query = (q or "").strip()[:60]
like = f"%{query}%" like = f"%{query}%"
limit = 8 limit = 8
@@ -873,7 +875,7 @@ def list_mentions(request: Request, q: str = ""):
if ws: if ws:
sql_ws = ( sql_ws = (
"SELECT p.id, p.title FROM pages p " "SELECT p.id, p.title FROM pages p "
"WHERE p.workspace_id=? AND p.deleted_at IS NULL AND (? = '' OR p.title LIKE ?) " "WHERE p.workspace_id=? AND p.deleted_at IS NULL AND COALESCE(p.search_excluded, 0) = 0 AND (? = '' OR p.title LIKE ?) "
"ORDER BY p.updated_at DESC LIMIT ?" "ORDER BY p.updated_at DESC LIMIT ?"
) )
files = [ files = [
@@ -922,7 +924,7 @@ def list_mentions(request: Request, q: str = ""):
for r in conn.execute( for r in conn.execute(
"SELECT p.id, p.title, w.name AS ws_name " "SELECT p.id, p.title, w.name AS ws_name "
"FROM pages p LEFT JOIN workspaces w ON w.id=p.workspace_id " "FROM pages p LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.deleted_at IS NULL AND (p.workspace_id IS ? OR p.workspace_id IS NULL) " "WHERE p.deleted_at IS NULL AND COALESCE(p.search_excluded, 0) = 0 AND (p.workspace_id IS ? OR p.workspace_id IS NULL) "
"AND (? = '' OR p.title LIKE ?) ORDER BY p.updated_at DESC LIMIT ?", "AND (? = '' OR p.title LIKE ?) ORDER BY p.updated_at DESC LIMIT ?",
(ws, query, like, limit) (ws, query, like, limit)
).fetchall(): ).fetchall():
@@ -939,7 +941,7 @@ def list_mentions(request: Request, q: str = ""):
for r in conn.execute( for r in conn.execute(
"SELECT p.id, p.title, w.name AS ws_name FROM pages p " "SELECT p.id, p.title, w.name AS ws_name FROM pages p "
"LEFT JOIN workspaces w ON w.id=p.workspace_id " "LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.deleted_at IS NULL AND (? = '' OR p.title LIKE ?) " "WHERE p.deleted_at IS NULL AND COALESCE(p.search_excluded, 0) = 0 AND (? = '' OR p.title LIKE ?) "
"ORDER BY p.updated_at DESC LIMIT ?", (query, like, limit) "ORDER BY p.updated_at DESC LIMIT ?", (query, like, limit)
).fetchall(): ).fetchall():
docs.append({"type": "document", "icon": "📄", "id": r["id"], docs.append({"type": "document", "icon": "📄", "id": r["id"],
+12
View File
@@ -102,6 +102,9 @@ def create_collection_v2(request: Request, authorization: str | None = Header(de
conn.commit() conn.commit()
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone() row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
audit_log(user, "collection.create", "collection", cid, name, request) audit_log(user, "collection.create", "collection", cid, name, request)
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.collection.created", f"collection:{cid}", name,
url=url_collection(cid), actor="user")
data = {"id": cid, "name": name, "status": "created", "collection": row_to_dict(row)} data = {"id": cid, "name": name, "status": "created", "collection": row_to_dict(row)}
key = (request.headers.get("Idempotency-Key") or "").strip() key = (request.headers.get("Idempotency-Key") or "").strip()
if key: if key:
@@ -349,6 +352,9 @@ def create_collection_page_v2(collection_id: int, request: Request, authorizatio
conn.commit() conn.commit()
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone() row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
audit_log(user, "page.create", "collection_page", pid, title, request) audit_log(user, "page.create", "collection_page", pid, title, request)
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.created", f"page:{pid}", title,
url=url_collection(collection_id), actor="user")
try: try:
run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title})) run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title}))
except Exception: except Exception:
@@ -426,6 +432,9 @@ def patch_page_v2(page_id: int, request: Request, authorization: str | None = He
conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id)) conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id))
conn.commit() conn.commit()
audit_log(user, "page.update", "collection_page", page_id, "", request) audit_log(user, "page.update", "collection_page", page_id, "", request)
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.updated", f"page:{page_id}", title,
url=url_collection(row["collection_id"]), actor="user")
try: try:
run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title})) run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title}))
except Exception: except Exception:
@@ -444,6 +453,9 @@ def delete_page_v2(page_id: int, request: Request, authorization: str | None = H
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,)) conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
conn.commit() conn.commit()
audit_log(user, "page.delete", "collection_page", page_id, "", request) audit_log(user, "page.delete", "collection_page", page_id, "", request)
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.deleted", f"page:{page_id}", row["title"],
url=url_collection(row["collection_id"]), actor="user")
try: try:
run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]})) run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]}))
except Exception: except Exception:
+8 -8
View File
@@ -235,9 +235,9 @@ def get_fd_template_v2(template_id: str, request: Request, authorization: str |
row = TemplateService.get_template(conn, _v2_actor(user), template_id) row = TemplateService.get_template(conn, _v2_actor(user), template_id)
return _row_to_dto(conn, row) return _row_to_dto(conn, row)
except LookupError: except LookupError:
raise HTTPException(404, "Template not found") raise HTTPException(404, "Template not found") from None
except PermissionError: except PermissionError:
raise HTTPException(403, "Forbidden") raise HTTPException(403, "Forbidden") from None
@router.post("/fd-templates/{template_id}/instantiate") @router.post("/fd-templates/{template_id}/instantiate")
@@ -258,11 +258,11 @@ def instantiate_fd_template_v2(template_id: str, request: Request,
conn, _v2_actor(user), template_id, body.get("variables") or {}, conn, _v2_actor(user), template_id, body.get("variables") or {},
body.get("context") or {}, idempotency_key=idem, run_source="api") body.get("context") or {}, idempotency_key=idem, run_source="api")
except LookupError: except LookupError:
raise HTTPException(404, "Template not found") raise HTTPException(404, "Template not found") from None
except PermissionError: except PermissionError:
raise HTTPException(403, "Forbidden") raise HTTPException(403, "Forbidden") from None
except ValueError as exc: except ValueError as exc:
raise HTTPException(400, str(exc)) raise HTTPException(400, str(exc)) from exc
audit_log(user, "template.instantiate", "template", template_id, "") audit_log(user, "template.instantiate", "template", template_id, "")
if idem: if idem:
store_idempotency(idem, user["id"], result, 200) store_idempotency(idem, user["id"], result, 200)
@@ -281,9 +281,9 @@ def export_fd_template_v2(template_id: str, request: Request, authorization: str
dto = _row_to_dto(conn, row) dto = _row_to_dto(conn, row)
blocks, _w = _read_holder_blocks(conn, row) blocks, _w = _read_holder_blocks(conn, row)
except LookupError: except LookupError:
raise HTTPException(404, "Template not found") raise HTTPException(404, "Template not found") from None
except PermissionError: except PermissionError:
raise HTTPException(403, "Forbidden") raise HTTPException(403, "Forbidden") from None
manifest = { manifest = {
"format": "flowdeck-template", "version": 1, "kind": dto["kind"], "format": "flowdeck-template", "version": 1, "kind": dto["kind"],
"name": dto["name"], "description": dto["description"], "icon": dto["icon"], "name": dto["name"], "description": dto["description"], "icon": dto["icon"],
@@ -308,7 +308,7 @@ def import_fd_template_v2(request: Request, authorization: str | None = Header(d
try: try:
version = int(body.get("version") or 0) version = int(body.get("version") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
raise HTTPException(400, "version invalide") raise HTTPException(400, "version invalide") from None
if version > 1: if version > 1:
raise HTTPException(400, "version de manifeste non supportée") raise HTTPException(400, "version de manifeste non supportée")
kind = body.get("kind") or "page" kind = body.get("kind") or "page"
+22 -2
View File
@@ -32,12 +32,32 @@ def get_redirect_uri(request: Request) -> str:
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080") host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
return f"{scheme}://{host}/auth/callback" return f"{scheme}://{host}/auth/callback"
def _sso_login_url() -> str:
"""URL du bouton « Login with ObsiGate (SSO) », résolue par la config.
Émettrice = DRACO_SSO_URL (défaut : nom officiel) ; retour =
URL publique de cette instance (FLOWDECK_URL / OAUTH_REDIRECT_URI /
APP_BASE_URL, défaut : nom officiel) + ``/auth/draco``. Aucun nom
public figé : un dev déclare ses noms en ``-dev`` dans le .env.
"""
from urllib.parse import quote
from app.services import draco_urls
retour = f"{draco_urls.flowdeck_url()}/auth/draco"
return (f"{draco_urls.obsigate_url()}/sso/authorize"
f"?return_to={quote(retour, safe='')}")
def _with_nonce(html: str) -> str: def _with_nonce(html: str) -> str:
"""A20 : injecte le nonce CSP au moment du rendu. """A20 : injecte le nonce CSP au moment du rendu.
`LOCAL_LOGIN_HTML` est une constante de module — le nonce, lui, est par `LOCAL_LOGIN_HTML` est une constante de module — le nonce, lui, est par
requête, donc il ne peut être figé qu'ici. requête, donc il ne peut être figé qu'ici. Même traitement pour l'URL
du bouton SSO ObsiGate (placeholder ``__SSO_LOGIN_URL__``), qui
dépend de la config dev/prod courante.
""" """
html = html.replace("__SSO_LOGIN_URL__", _sso_login_url())
return html.replace("<script>", f'<script nonce="{CSP_NONCE.get()}">', 1) return html.replace("<script>", f'<script nonce="{CSP_NONCE.get()}">', 1)
@@ -103,7 +123,7 @@ body{background:#191919;color:#fff;font-family:-apple-system,BlinkMacSystemFont,
<p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p> <p style="font-size:13px;color:rgba(255,255,255,.4);margin-bottom:8px;">Or continue with</p>
<button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button> <button class="oauth-btn" onclick="window.location='/auth/login?provider=gitea'" style="margin-bottom:8px">🔗 <span id="gitea-btn-label">Login</span> with Gitea</button>
<button class="oauth-btn" onclick="window.location='/auth/login?provider=github'" style="margin-bottom:8px">🐙 <span id="github-btn-label">Login</span> with GitHub</button> <button class="oauth-btn" onclick="window.location='/auth/login?provider=github'" style="margin-bottom:8px">🐙 <span id="github-btn-label">Login</span> with GitHub</button>
<button class="oauth-btn" onclick="window.location='https://og.dracodev.net/sso/authorize?return_to=https%3A%2F%2Fflowdeck.dracodev.net%2Fauth%2Fdraco'">🧭 Login with ObsiGate (SSO)</button> <button class="oauth-btn" onclick="window.location='__SSO_LOGIN_URL__'">🧭 Login with ObsiGate (SSO)</button>
</div> </div>
<!-- SSO / SAML + OIDC (v6.7.0) — buttons injected by loadSsoProviders() --> <!-- SSO / SAML + OIDC (v6.7.0) — buttons injected by loadSsoProviders() -->
<div class="oauth-section" id="sso-section" style="display:none"> <div class="oauth-section" id="sso-section" style="display:none">
+3
View File
@@ -86,6 +86,9 @@ def delete_page(request: Request, page_id: int):
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,)) conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
conn.commit() conn.commit()
run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})) run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}))
from app.services.agent_evenements import emettre, url_page
emettre("flowdeck.page.deleted", f"page:{page_id}", row["title"] or "",
url=url_page(page_id), actor="user")
return {"status": "ok", "deleted": page_id, "title": row["title"]} return {"status": "ok", "deleted": page_id, "title": row["title"]}
+8
View File
@@ -55,6 +55,9 @@ def create_page(request: Request, title: str = Query(default=""),
page_id = cur.lastrowid page_id = cur.lastrowid
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title, run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title,
"workspace": ws_key, "parent_id": parent_id})) "workspace": ws_key, "parent_id": parent_id}))
from app.services.agent_evenements import emettre, url_page
emettre("flowdeck.page.created", f"page:{page_id}", page_title,
url=url_page(page_id), actor="user")
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id} return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
except Exception as e: except Exception as e:
logger.error("create_page failed: %s", e) logger.error("create_page failed: %s", e)
@@ -109,9 +112,14 @@ def update_page(request: Request, page_id: int, title: str = Query(default=""),
if content_format: if content_format:
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id)) conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
conn.commit() conn.commit()
trow = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
page_label = (trow["title"] if trow else title) or ""
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title, run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
"content_format": content_format or "markdown", "content_format": content_format or "markdown",
"actor_id": user.get("id")})) "actor_id": user.get("id")}))
from app.services.agent_evenements import emettre, url_page
emettre("flowdeck.page.updated", f"page:{page_id}", page_label,
url=url_page(page_id), actor="user")
return {"status": "ok"} return {"status": "ok"}
+3
View File
@@ -118,6 +118,9 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
conn.commit() conn.commit()
run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon})) run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.collection.created", f"collection:{collection_id}", name,
url=url_collection(collection_id), actor="user")
return {"id": collection_id, "name": name, "status": "created"} return {"id": collection_id, "name": name, "status": "created"}
+3
View File
@@ -119,4 +119,7 @@ def create_page_api(request: Request, collection_id: int, body: dict = Body(defa
"collection_id": collection_id, "collection_id": collection_id,
"title": title, "title": title,
})) }))
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.created", f"page:{page_id}", title,
url=url_collection(collection_id), actor="user")
return {"id": page_id, "title": title, "status": "created"} return {"id": page_id, "title": title, "status": "created"}
+6
View File
@@ -149,6 +149,9 @@ def update_page_api(request: Request, page_id: int, body: dict = Body(default={}
user = _current_user(request) user = _current_user(request)
notify_assignment(existing["collection_id"], page_id, title, notify_assignment(existing["collection_id"], page_id, title,
stored, props, user.get("id")) stored, props, user.get("id"))
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.updated", f"page:{page_id}", title,
url=url_collection(existing["collection_id"]), actor="user")
return {"id": page_id, "status": "updated"} return {"id": page_id, "status": "updated"}
@@ -178,6 +181,9 @@ def delete_page_api(request: Request, page_id: int):
"page_id": page_id, "page_id": page_id,
"collection_id": existing["collection_id"], "collection_id": existing["collection_id"],
})) }))
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.deleted", f"page:{page_id}", existing["title"],
url=url_collection(existing["collection_id"]), actor="user")
return {"id": page_id, "status": "deleted"} return {"id": page_id, "status": "deleted"}
+1
View File
@@ -107,6 +107,7 @@ def _recent_sidebar_pages(uid: int, limit: int = 8) -> list:
FROM pages p FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id LEFT JOIN workspaces w ON w.id = p.workspace_id
WHERE p.deleted_at IS NULL AND p.parent_section != 'Trash' WHERE p.deleted_at IS NULL AND p.parent_section != 'Trash'
AND COALESCE(p.search_excluded, 0) = 0
AND (w.owner_id = ? OR p.workspace_id IS NULL) AND (w.owner_id = ? OR p.workspace_id IS NULL)
AND REPLACE(COALESCE(p.content,''), ' ', '') AND REPLACE(COALESCE(p.content,''), ' ', '')
NOT LIKE '%"type":"meeting"%' NOT LIKE '%"type":"meeting"%'
+2 -1
View File
@@ -222,7 +222,8 @@ async def list_workspace_projects(request: Request):
builtin = [] builtin = []
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20" "SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL "
"AND COALESCE(search_excluded, 0) = 0 ORDER BY updated_at DESC LIMIT 20"
).fetchall() ).fetchall()
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne. # A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
counts = {} counts = {}
+13 -6
View File
@@ -210,6 +210,12 @@ BASE_SELECT = (
"p.parent_id, p.share_mode, p.parent_section, p.page_icon" "p.parent_id, p.share_mode, p.parent_section, p.page_icon"
) )
# Pages-supports de templates (fd_templates._create_holder) : pages internes
# marquées search_excluded=1 — « masquées, jamais listées ». Elles ne doivent
# apparaître dans AUCUN onglet de la bibliothèque (une base fraîche en sème 99,
# qui noieraient sinon recents/private/workspace sous leur updated_at = now).
NO_HOLDER = "COALESCE(p.search_excluded, 0) = 0"
# Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python). # Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python).
MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'" MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
@@ -240,7 +246,7 @@ def library_meetings(
" ORDER BY mt.id DESC LIMIT 1) AS meeting_summary " " ORDER BY mt.id DESC LIMIT 1) AS meeting_summary "
"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id " "FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
"WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) " "WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) "
f"AND {MEETING_BLOCK_LIKE}" f"AND {NO_HOLDER} AND {MEETING_BLOCK_LIKE}"
) )
params: list = [uid] params: list = [uid]
query, params = _apply_source_filter(query, params, source_type) query, params = _apply_source_filter(query, params, source_type)
@@ -275,7 +281,7 @@ def library_recents(
source_type = "all" source_type = "all"
uid = _get_user_id(request) uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}" query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER} AND {NO_MEETING_BLOCK}"
params: list = [] params: list = []
# Hierarchical view: show only root-level pages at the top # Hierarchical view: show only root-level pages at the top
@@ -313,6 +319,7 @@ def library_favorites(
"p.parent_id, p.share_mode, p.parent_section, 1 as favorited " "p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
"FROM favorites f JOIN pages p ON p.id = f.page_id " "FROM favorites f JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL " "WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL "
f"AND {NO_HOLDER}"
) )
params: list = [uid] params: list = [uid]
if tree: if tree:
@@ -379,7 +386,7 @@ def library_shared(
query = ( query = (
f"{BASE_SELECT} FROM pages p " f"{BASE_SELECT} FROM pages p "
f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL" f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER}"
) )
if tree: if tree:
query += " AND p.parent_id IS NULL" query += " AND p.parent_id IS NULL"
@@ -410,7 +417,7 @@ def library_published(
uid = _get_user_id(request) uid = _get_user_id(request)
# v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin). # v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin).
query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL" query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER}"
params: list = [] params: list = []
if tree: if tree:
query += " AND p.parent_id IS NULL" query += " AND p.parent_id IS NULL"
@@ -435,7 +442,7 @@ def library_private(
source_type = "all" source_type = "all"
uid = _get_user_id(request) uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}" query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_HOLDER} AND {NO_MEETING_BLOCK}"
params: list = [] params: list = []
if tree: if tree:
query += " AND p.parent_id IS NULL" query += " AND p.parent_id IS NULL"
@@ -501,7 +508,7 @@ def library_workspace(
source_type = "all" source_type = "all"
uid = _get_user_id(request) uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}" query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER} AND {NO_MEETING_BLOCK}"
params: list = [] params: list = []
if tree: if tree:
+59 -17
View File
@@ -28,15 +28,20 @@ from fastapi.responses import JSONResponse, Response
from app.db import get_conn from app.db import get_conn
from app.routers.public_api import _token_owner from app.routers.public_api import _token_owner
from app.services.collection_lifecycle import (
effective_workspace_sql,
user_workspace_ids,
)
router = APIRouter(tags=["mcp"]) router = APIRouter(tags=["mcp"])
OUTILS = [ OUTILS = [
{"name": "list_workspaces", {"name": "list_workspaces",
"description": "Liste les WORKSPACES Flowdeck — les conteneurs de pages " "description": "Liste les WORKSPACES Flowdeck du propriétaire du jeton "
"de l'utilisateur (ex. Techno, Famille Charest), avec leur " "(propriétaire ou membre) — ses conteneurs de pages "
"nombre de pages actives. À ne pas confondre avec les " "(ex. Techno, Famille Charest), avec leur nombre de pages "
"collections (bases de données).", "actives. À ne pas confondre avec les collections "
"(bases de données).",
"inputSchema": {"type": "object", "properties": {}}}, "inputSchema": {"type": "object", "properties": {}}},
{"name": "get_workspace", {"name": "get_workspace",
"description": "Lit un workspace et l'arborescence de ses pages " "description": "Lit un workspace et l'arborescence de ses pages "
@@ -112,19 +117,41 @@ def _page_resume(row) -> dict:
"updated_at": row["updated_at"]} "updated_at": row["updated_at"]}
def _appel_outil(nom: str, args: dict): def _appel_outil(nom: str, args: dict, user_id: int):
"""Exécute un outil DANS LE PÉRIMÈTRE du propriétaire du jeton.
Correctif 7.78.0 (cas réel de Bruno) : la première version listait
les 52 workspaces de la base — tous les comptes, résidus de tests
compris. Règle : workspaces dont l'utilisateur est propriétaire
ou membre ; collections rattachées (directement ou via leur page
hôte) à l'un de ces workspaces, plus les collections sans aucun
rattachement (héritage global, comme l'API publique historique).
Un objet hors périmètre répond « introuvable », sans fuiter son
existence.
"""
with get_conn() as conn: with get_conn() as conn:
accessible = set(user_workspace_ids(conn, user_id))
eff_sql = effective_workspace_sql("c")
def collection_visible(coll_id: int) -> bool:
row = conn.execute(
f"SELECT {eff_sql} AS eff FROM collections c WHERE c.id = ?",
(coll_id,)).fetchone()
if row is None:
return False
return row["eff"] is None or row["eff"] in accessible
if nom == "list_workspaces": if nom == "list_workspaces":
rows = conn.execute( rows = conn.execute(
"SELECT w.id, w.name, w.owner_id, " "SELECT w.id, w.name, w.owner_id, "
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id = w.id " "(SELECT COUNT(*) FROM pages p WHERE p.workspace_id = w.id "
" AND p.deleted_at IS NULL) AS page_count " " AND p.deleted_at IS NULL) AS page_count "
"FROM workspaces w ORDER BY w.name").fetchall() "FROM workspaces w ORDER BY w.name").fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows if r["id"] in accessible]
if nom == "get_workspace": if nom == "get_workspace":
ws = conn.execute("SELECT * FROM workspaces WHERE id = ?", ws = conn.execute("SELECT * FROM workspaces WHERE id = ?",
(int(args.get("id", 0)),)).fetchone() (int(args.get("id", 0)),)).fetchone()
if ws is None: if ws is None or ws["id"] not in accessible:
return {"erreur": "workspace introuvable"} return {"erreur": "workspace introuvable"}
pages = conn.execute( pages = conn.execute(
"SELECT id, title, parent_id, parent_section, updated_at " "SELECT id, title, parent_id, parent_section, updated_at "
@@ -135,17 +162,20 @@ def _appel_outil(nom: str, args: dict):
"pages": [dict(p) for p in pages]} "pages": [dict(p) for p in pages]}
if nom == "list_collections": if nom == "list_collections":
rows = conn.execute( rows = conn.execute(
"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p " f"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p "
"WHERE p.collection_id = c.id) AS page_count " f"WHERE p.collection_id = c.id) AS page_count, "
f"{eff_sql} AS eff_ws "
"FROM collections c ORDER BY c.name").fetchall() "FROM collections c ORDER BY c.name").fetchall()
return [{"id": r["id"], "name": r["name"], return [{"id": r["id"], "name": r["name"],
"description": r["description"], "icon": r["icon"], "description": r["description"], "icon": r["icon"],
"is_task": bool(r["is_task"]) if "is_task" in r.keys() else False, "is_task": bool(r["is_task"]) if "is_task" in r.keys() else False,
"page_count": r["page_count"]} for r in rows] "page_count": r["page_count"]}
for r in rows
if r["eff_ws"] is None or r["eff_ws"] in accessible]
if nom == "get_collection": if nom == "get_collection":
coll = conn.execute("SELECT * FROM collections WHERE id = ?", coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(int(args.get("id", 0)),)).fetchone() (int(args.get("id", 0)),)).fetchone()
if coll is None: if coll is None or not collection_visible(coll["id"]):
return {"erreur": "collection introuvable"} return {"erreur": "collection introuvable"}
pages = conn.execute( pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id = ? " "SELECT * FROM collection_pages WHERE collection_id = ? "
@@ -157,7 +187,7 @@ def _appel_outil(nom: str, args: dict):
if nom == "get_page": if nom == "get_page":
p = conn.execute("SELECT * FROM collection_pages WHERE id = ?", p = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(int(args.get("id", 0)),)).fetchone() (int(args.get("id", 0)),)).fetchone()
if p is None: if p is None or not collection_visible(p["collection_id"]):
return {"erreur": "page introuvable"} return {"erreur": "page introuvable"}
res = _page_resume(p) res = _page_resume(p)
for cle in ("content", "blocks", "blocks_json"): for cle in ("content", "blocks", "blocks_json"):
@@ -171,7 +201,8 @@ def _appel_outil(nom: str, args: dict):
rows = conn.execute( rows = conn.execute(
"SELECT * FROM collection_pages WHERE title LIKE ? " "SELECT * FROM collection_pages WHERE title LIKE ? "
"ORDER BY updated_at DESC LIMIT 25", (q,)).fetchall() "ORDER BY updated_at DESC LIMIT 25", (q,)).fetchall()
return [_page_resume(p) for p in rows] return [_page_resume(p) for p in rows
if collection_visible(p["collection_id"])]
if nom == "list_repos": if nom == "list_repos":
rows = conn.execute( rows = conn.execute(
"SELECT id, name, proj_type, owner, language, description " "SELECT id, name, proj_type, owner, language, description "
@@ -187,7 +218,7 @@ def _appel_outil(nom: str, args: dict):
return {"erreur": "titre vide"} return {"erreur": "titre vide"}
coll = conn.execute("SELECT * FROM collections WHERE id = ?", coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(collection_id,)).fetchone() (collection_id,)).fetchone()
if coll is None: if coll is None or not collection_visible(collection_id):
return {"erreur": "collection introuvable"} return {"erreur": "collection introuvable"}
props = args.get("properties") props = args.get("properties")
if props is None: if props is None:
@@ -206,6 +237,9 @@ def _appel_outil(nom: str, args: dict):
conn.commit() conn.commit()
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?", page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(cur.lastrowid,)).fetchone() (cur.lastrowid,)).fetchone()
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.created", f"page:{page['id']}", title,
url=url_collection(collection_id), actor="agent:mcp")
return _page_resume(page) return _page_resume(page)
if nom == "update_page": if nom == "update_page":
try: try:
@@ -214,7 +248,7 @@ def _appel_outil(nom: str, args: dict):
return {"erreur": "id requis (entier)"} return {"erreur": "id requis (entier)"}
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?", page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(page_id,)).fetchone() (page_id,)).fetchone()
if page is None: if page is None or not collection_visible(page["collection_id"]):
return {"erreur": "page introuvable"} return {"erreur": "page introuvable"}
title = page["title"] title = page["title"]
if "title" in args and args.get("title") is not None: if "title" in args and args.get("title") is not None:
@@ -234,6 +268,9 @@ def _appel_outil(nom: str, args: dict):
conn.commit() conn.commit()
updated = conn.execute("SELECT * FROM collection_pages WHERE id = ?", updated = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
(page_id,)).fetchone() (page_id,)).fetchone()
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.page.updated", f"page:{page_id}", title,
url=url_collection(page["collection_id"]), actor="agent:mcp")
return _page_resume(updated) return _page_resume(updated)
if nom == "create_collection": if nom == "create_collection":
name = str(args.get("name") or "").strip() name = str(args.get("name") or "").strip()
@@ -256,6 +293,9 @@ def _appel_outil(nom: str, args: dict):
conn.commit() conn.commit()
coll = conn.execute("SELECT * FROM collections WHERE id = ?", coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(new_id,)).fetchone() (new_id,)).fetchone()
from app.services.agent_evenements import emettre, url_collection
emettre("flowdeck.collection.created", f"collection:{new_id}", name,
url=url_collection(new_id), actor="agent:mcp")
return {"id": coll["id"], "name": coll["name"], return {"id": coll["id"], "name": coll["name"],
"description": coll["description"], "icon": coll["icon"]} "description": coll["description"], "icon": coll["icon"]}
return {"erreur": f"outil inconnu : {nom}"} return {"erreur": f"outil inconnu : {nom}"}
@@ -281,7 +321,8 @@ def mcp_info():
async def mcp(request: Request): async def mcp(request: Request):
auth = request.headers.get("authorization", "") auth = request.headers.get("authorization", "")
token = auth[7:].strip() if auth.lower().startswith("bearer ") else "" token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
if not token or _token_owner(token) is None: proprietaire = _token_owner(token) if token else None
if proprietaire is None:
return JSONResponse( return JSONResponse(
{"jsonrpc": "2.0", "id": None, {"jsonrpc": "2.0", "id": None,
"error": {"code": -32001, "message": "Authentification requise (jeton d'API Flowdeck)"}}, "error": {"code": -32001, "message": "Authentification requise (jeton d'API Flowdeck)"}},
@@ -308,7 +349,8 @@ async def mcp(request: Request):
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": OUTILS}} return {"jsonrpc": "2.0", "id": rid, "result": {"tools": OUTILS}}
if methode == "tools/call": if methode == "tools/call":
params = data.get("params") or {} params = data.get("params") or {}
res = _appel_outil(params.get("name", ""), params.get("arguments") or {}) res = _appel_outil(params.get("name", ""), params.get("arguments") or {},
proprietaire["user_id"])
return {"jsonrpc": "2.0", "id": rid, return {"jsonrpc": "2.0", "id": rid,
"result": {"content": [{"type": "text", "result": {"content": [{"type": "text",
"text": json.dumps(res, ensure_ascii=False)}]}} "text": json.dumps(res, ensure_ascii=False)}]}}
+132
View File
@@ -0,0 +1,132 @@
"""Émission d'événements vers le journal du service d'agents
(Fusion, Lot 2 — contrat ACC-0.2).
Flowdeck émet ses mutations significatives (pages et collections)
vers `POST /api/v1/evenements` du service d'agents, qui les expose
dans le « Journal d'aujourd'hui » de la console. L'enveloppe ne
contient qu'une référence, un libellé et un lien — jamais le
contenu de l'objet : {id, type, occurred_at, actor, domain,
source_app, object{ref, label, url}}.
Authentification : signature HMAC-SHA256 du corps brut, en-tête
`X-Draco-Signature`, avec la clé propre à Flowdeck
(`AGENT_EVENEMENTS_CLE`, jamais en dur). Sans clé, le module est
INACTIF : aucun appel, aucun bruit.
Discipline « au mieux » (même esprit que le journal du service) :
l'envoi part dans un fil d'arrière-plan démon, délai maximal 2 s,
et toute erreur est avalée avec une ligne de journal — une panne
du récepteur ne doit jamais casser ni ralentir le métier.
Aucun événement « meeting » n'est émis ici : la décision A/B de
Bruno sur le bloc meeting de Flowdeck est toujours ouverte.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
import threading
import uuid
from datetime import UTC, datetime
import httpx
log = logging.getLogger("flowdeck.evenements")
SOURCE_APP = "flowdeck"
DELAI_S = 2.0
def _cle() -> str:
return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip()
def _url() -> str:
# AGENT_EVENEMENTS_URL (en service) prime ; à défaut, l'endpoint
# est dérivé de la base de l'agent résolue par la config
# (DRACO_AGENT_URL, défaut = nom officiel) — aucun nom figé.
from app.services import draco_urls
return draco_urls.agent_evenements_url()
def _url_defaut() -> str:
return _url()
# Compat : ancien nom lu par d'éventuels consommateurs externes.
URL_DEFAUT = _url()
def actif() -> bool:
"""L'émission n'existe que si la clé de Flowdeck est configurée."""
return bool(_cle())
def url_page(page_id: int) -> str:
"""Lien profond d'une page d'espace de travail (vue HTML du board)."""
return f"/board/pages/{page_id}"
def url_collection(collection_id: int) -> str:
"""Lien profond d'une collection (sa vue tableau). Une page de
collection n'a pas d'URL autonome : elle s'ouvre dans cette vue."""
return f"/db/{collection_id}/view/table"
def construire_enveloppe(type_: str, objet_ref: str, label: str,
url: str = "/", domain: str = "pro",
actor: str | None = None) -> tuple[dict, bytes]:
"""L'enveloppe ACC-0.2 et son corps JSON exact (celui qui est
signé puis envoyé — la signature porte sur ces octets-là)."""
env = {
"id": uuid.uuid4().hex,
"type": type_,
"occurred_at": datetime.now(UTC).isoformat(),
"actor": actor or "user",
"domain": domain or "pro",
"source_app": SOURCE_APP,
"object": {"ref": objet_ref, "label": label or "", "url": url or "/"},
}
corps = json.dumps(env, ensure_ascii=False).encode("utf-8")
return env, corps
def _signer(corps: bytes) -> str:
return hmac.new(_cle().encode("utf-8"), corps, hashlib.sha256).hexdigest()
def _envoyer(corps: bytes) -> None:
"""Le POST signé, dans le fil d'arrière-plan. Avale tout : le
journal distant est un confort, jamais une dépendance."""
try:
with httpx.Client(timeout=DELAI_S, trust_env=False) as client:
client.post(
_url(), content=corps,
headers={"Content-Type": "application/json",
"X-Draco-Signature": _signer(corps)},
)
except Exception as exc: # panne, délai, DNS… : on log et on passe
log.warning("événement non livré au journal du service (%s)", exc)
def emettre(type_: str, objet_ref: str, label: str, url: str = "/",
domain: str = "pro", actor: str | None = None) -> bool:
"""Émet un événement en tâche de fond. Retourne True si l'envoi
a été déclenché, False si le module est inactif (pas de clé) ou
si l'enveloppe n'a pas pu être construite. Ne lève jamais."""
if not actif():
return False
try:
_env, corps = construire_enveloppe(type_, objet_ref, label, url,
domain, actor)
except Exception:
log.warning("événement %s non construit", type_, exc_info=True)
return False
threading.Thread(target=_envoyer, args=(corps,), daemon=True,
name="flowdeck-evenements").start()
return True
+36 -2
View File
@@ -28,7 +28,10 @@ CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp", "tiktak")
AUTH_SCHEMES = ("bearer", "bot", "none") AUTH_SCHEMES = ("bearer", "bot", "none")
# Presets proposés par le formulaire du menu + (le jeton reste toujours saisi # Presets proposés par le formulaire du menu + (le jeton reste toujours saisi
# par l'utilisateur — jamais codé en dur ici). # par l'utilisateur — jamais codé en dur ici).
PRESETS = { def _presets() -> dict:
from app.services import draco_urls
return {
"discord": {"url": "https://discord.com/api/v10", "auth": "bot", "discord": {"url": "https://discord.com/api/v10", "auth": "bot",
"hint": "Bot Discord Developers → Token"}, "hint": "Bot Discord Developers → Token"},
"telegram": {"url": "https://api.telegram.org/bot{secret}", "auth": "none", "telegram": {"url": "https://api.telegram.org/bot{secret}", "auth": "none",
@@ -39,9 +42,40 @@ PRESETS = {
# lecture de TikTak (POST /mcp, JSON-RPC) ; la clé `tck_` se crée # lecture de TikTak (POST /mcp, JSON-RPC) ; la clé `tck_` se crée
# dans TikTak → Réglages → Intégrations. Comportement MCP standard # dans TikTak → Réglages → Intégrations. Comportement MCP standard
# (probe, cache d'outils, outils dynamiques mcp_tiktak_*). # (probe, cache d'outils, outils dynamiques mcp_tiktak_*).
"tiktak": {"url": "https://tiktak.dracodev.net/mcp", "auth": "bearer", # v7.82.0 : URL dérivée de la config (TIKTAK_URL ; défaut officiel).
"tiktak": {"url": f"{draco_urls.tiktak_url()}/mcp", "auth": "bearer",
"hint": "TikTak → Réglages → Intégrations → clé API tck_…"}, "hint": "TikTak → Réglages → Intégrations → clé API tck_…"},
} }
class _Presets(dict):
"""Vue dict des presets, résolue à chaque accès (config dev/prod).
``PRESETS`` reste un dict pour ses lecteurs historiques
(``PRESETS["tiktak"]["url"]``), mais l'URL TikTak suit la config
courante au lieu d'être figée à l'import.
"""
def __getitem__(self, key):
return _presets()[key]
def __contains__(self, key):
return key in _presets()
def get(self, key, default=None):
return _presets().get(key, default)
def keys(self):
return _presets().keys()
def items(self):
return _presets().items()
def values(self):
return _presets().values()
PRESETS = _Presets()
OAUTH_KINDS = oauth_connectors.OAUTH_KINDS OAUTH_KINDS = oauth_connectors.OAUTH_KINDS
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe) FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
+3 -1
View File
@@ -97,7 +97,9 @@ def _refresh_revocations() -> None:
if now - _revoked_fetched_at < _REVOCATIONS_TTL: if now - _revoked_fetched_at < _REVOCATIONS_TTL:
return return
_revoked_fetched_at = now _revoked_fetched_at = now
base = os.environ.get("DRACO_SSO_URL", "https://og.dracodev.net") from app.services import draco_urls
base = draco_urls.obsigate_url()
try: try:
import httpx import httpx
+154
View File
@@ -0,0 +1,154 @@
"""URLs publiques des applications dracodev — résolution dev/prod.
Source unique pour tout le code vivant qui doit nommer une autre
application (CSP, panneau Spark, barre commune, SSO, presets MCP,
références croisées…). Règle du chantier dev/prod : **aucun nom
public codé en dur ailleurs** ; les défauts ci-dessous sont les noms
OFFICIELS (production) et un environnement dev déclare ses noms en
``-dev`` dans le ``.env`` (qui survit aux déploiements par release,
car il est passé par ``environment:``/``env_file:``) :
DRACO_SSO_URL=https://og-dev.dracodev.net
DRACO_AGENT_URL=https://agent-dev.dracodev.net
TIKTAK_URL=https://tiktak-dev.dracodev.net
KANBANTE_URL=https://kanbante-dev.dracodev.net
FLOWDECK_URL=https://flowdeck-dev.dracodev.net (ou OAUTH_REDIRECT_URI)
Priorité de résolution, à chaque appel (les tests et les modules qui
lisaient déjà ``os.environ`` au moment de l'appel gardent ce
comportement) : variable d'environnement > ``app.config.settings`` >
défaut officiel. Aucun défaut ne contient ``-dev``.
"""
from __future__ import annotations
import os
from urllib.parse import urlparse
from app.config import settings
OFFICIELLES = {
"obsigate": "https://og.dracodev.net",
"agent": "https://agent.dracodev.net",
"tiktak": "https://tiktak.dracodev.net",
"kanbante": "https://kanbante.dracodev.net",
"flowdeck": "https://flowdeck.dracodev.net",
"gitea": "https://git.dracodev.net",
}
def _nettoyer(url: str) -> str:
return (url or "").strip().rstrip("/")
def _resoudre(env_name: str, settings_attr: str, app: str) -> str:
env = _nettoyer(os.environ.get(env_name, ""))
if env:
return env
cfg = _nettoyer(getattr(settings, settings_attr, "") or "")
if cfg:
return cfg
return OFFICIELLES[app]
def obsigate_url() -> str:
"""ObsiGate — aussi l'émettrice SSO (DRACO_SSO_URL, déjà en service)."""
return _resoudre("DRACO_SSO_URL", "draco_sso_url", "obsigate")
def agent_url() -> str:
"""Service d'agents (Spark) — base sans chemin."""
return _resoudre("DRACO_AGENT_URL", "draco_agent_url", "agent")
def tiktak_url() -> str:
return _resoudre("TIKTAK_URL", "tiktak_url", "tiktak")
def kanbante_url() -> str:
return _resoudre("KANBANTE_URL", "kanbante_url", "kanbante")
def gitea_url() -> str:
return _resoudre("GITEA_URL", "gitea_url", "gitea")
def flowdeck_url() -> str:
"""URL publique de cette instance de Flowdeck.
FLOWDECK_URL > origine de OAUTH_REDIRECT_URI (déjà déclarée en
dev : ``https://flowdeck-dev.dracodev.net/auth/callback``) >
APP_BASE_URL si elle n'est pas locale > nom officiel.
"""
env = _nettoyer(os.environ.get("FLOWDECK_URL", ""))
if env:
return env
cfg = _nettoyer(getattr(settings, "flowdeck_url", "") or "")
if cfg:
return cfg
for candidate in (
os.environ.get("OAUTH_REDIRECT_URI", ""),
getattr(settings, "oauth_redirect_uri", "") or "",
):
candidate = _nettoyer(candidate)
if candidate:
p = urlparse(candidate)
if p.scheme and p.netloc:
return f"{p.scheme}://{p.netloc}"
base = _nettoyer(getattr(settings, "app_base_url", "") or "")
if base and "localhost" not in base and "127.0.0.1" not in base:
return base
return OFFICIELLES["flowdeck"]
def agent_evenements_url() -> str:
"""Endpoint du journal d'événements du service d'agents.
AGENT_EVENEMENTS_URL (déjà en service) prime ; sinon dérivée de
la base de l'agent — jamais un nom figé.
"""
env = _nettoyer(os.environ.get("AGENT_EVENEMENTS_URL", ""))
if env:
return env
cfg = _nettoyer(getattr(settings, "agent_evenements_url", "") or "")
if cfg:
return cfg
return f"{agent_url()}/api/v1/evenements"
def origine(url: str) -> str:
"""``scheme://netloc`` d'une URL (pour la CSP)."""
p = urlparse(url or "")
if p.scheme and p.netloc:
return f"{p.scheme}://{p.netloc}"
return _nettoyer(url)
def hote(url: str) -> str:
return (urlparse(url or "").hostname or "").lower()
def toutes() -> dict[str, str]:
"""Toutes les URL résolues — injectées aux templates (hôte = config)."""
return {
"obsigate": obsigate_url(),
"agent": agent_url(),
"tiktak": tiktak_url(),
"kanbante": kanbante_url(),
"flowdeck": flowdeck_url(),
"gitea": gitea_url(),
}
def hotes_connus(app: str) -> set[str]:
"""Hôtes reconnus pour ``app`` : l'hôte configuré **et** l'hôte
officiel. Les références croisées déjà stockées portent des URL
absolues (officielles aujourd'hui) : elles doivent rester
reconnaissables dans un environnement dev, et inversement —
aucune migration de données n'est faite pour un changement de nom.
"""
resolues = toutes()
hotes = {hote(OFFICIELLES[app])}
if app in resolues:
hotes.add(hote(resolues[app]))
return {h for h in hotes if h}
+51 -4
View File
@@ -35,14 +35,22 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
except ValueError: except ValueError:
return None return None
host = (u.hostname or "").lower() host = (u.hostname or "").lower()
if host == "tiktak.dracodev.net": # v7.82.0 (dev/prod) : les hôtes reconnus viennent de la config
# (TIKTAK_URL, DRACO_SSO_URL, FLOWDECK_URL/OAUTH_REDIRECT_URI,
# GITEA_URL/KANBANTE_URL) — un dev colle des URL en `-dev`, la prod
# des URL officielles. L'hôte officiel reste TOUJOURS reconnu en
# plus de l'hôte configuré : les références déjà stockées portent
# des URL absolues et aucune donnée n'est migrée pour un renommage.
from app.services import draco_urls
if host in draco_urls.hotes_connus("tiktak"):
q = parse_qs(u.query) q = parse_qs(u.query)
if q.get("task"): if q.get("task"):
return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}") return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}")
if q.get("list"): if q.get("list"):
return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}") return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}")
return None return None
if host == "og.dracodev.net": if host in draco_urls.hotes_connus("obsigate"):
frag = u.fragment or "" frag = u.fragment or ""
if frag.startswith("file="): if frag.startswith("file="):
cible = unquote(frag[5:]) cible = unquote(frag[5:])
@@ -50,7 +58,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
nom = cible.rsplit("/", 1)[-1] nom = cible.rsplit("/", 1)[-1]
return ("obsigate", "note", cible, f"ObsiGate · {nom}") return ("obsigate", "note", cible, f"ObsiGate · {nom}")
return None return None
if host == "flowdeck.dracodev.net": if host in draco_urls.hotes_connus("flowdeck"):
m = re.fullmatch(r"/pages/(\d+)", u.path or "") m = re.fullmatch(r"/pages/(\d+)", u.path or "")
if m: if m:
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}") return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
@@ -58,7 +66,7 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
if m: if m:
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}") return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
return None return None
if host == "git.dracodev.net": if host in draco_urls.hotes_connus("gitea") | draco_urls.hotes_connus("kanbante"):
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "") m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
if m: if m:
owner, repo, num = m.groups() owner, repo, num = m.groups()
@@ -67,9 +75,48 @@ def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
return None return None
def url_affichage(target_url: str) -> str:
"""URL d'affichage d'une référence : l'hôte vient de la config.
Les références **stockées** portent l'URL absolue collée par
l'utilisateur (``target_url``, jamais migrée ni réécrite en base).
À l'affichage, si l'hôte stocké est un hôte dracodev connu
(officiel ou configuré), il est remplacé par l'hôte configuré de
l'environnement courant — chemin, requête et fragment intacts :
une réf créée en prod s'ouvre sur le dev quand on la consulte
depuis le dev, et inversement. Hôte inconnu : URL inchangée.
"""
try:
u = urlparse((target_url or "").strip())
except ValueError:
return target_url
host = (u.hostname or "").lower()
if not host:
return target_url
from app.services import draco_urls
base = None
for app, url in (
("tiktak", draco_urls.tiktak_url()),
("obsigate", draco_urls.obsigate_url()),
("flowdeck", draco_urls.flowdeck_url()),
("gitea", draco_urls.gitea_url()),
("kanbante", draco_urls.kanbante_url()),
):
if host in draco_urls.hotes_connus(app):
base = url
break
if base is None:
return target_url
return u._replace(scheme=urlparse(base).scheme,
netloc=urlparse(base).netloc).geturl()
def _row_to_ref(row) -> dict: def _row_to_ref(row) -> dict:
d = dict(row) d = dict(row)
d["app_color"] = APP_COLORS.get(d["target_app"], "#888") d["app_color"] = APP_COLORS.get(d["target_app"], "#888")
# Affichage seulement : la valeur stockée en base n'est pas touchée.
d["target_url"] = url_affichage(d.get("target_url") or "")
return d return d
-6
View File
@@ -714,7 +714,6 @@ class TemplateService:
cols = _cols(conn, "pages") cols = _cols(conn, "pages")
ws_name = (_workspace_name(conn, workspace_id) if workspace_id else "") or "Private" ws_name = (_workspace_name(conn, workspace_id) if workspace_id else "") or "Private"
try: try:
uid = _actor_id(actor)
data: dict[str, Any] = {} data: dict[str, Any] = {}
if "workspace" in cols: if "workspace" in cols:
data["workspace"] = ws_name data["workspace"] = ws_name
@@ -740,7 +739,6 @@ class TemplateService:
f"INSERT INTO pages ({keys}) VALUES ({placeholders})", f"INSERT INTO pages ({keys}) VALUES ({placeholders})",
tuple(data.values()), tuple(data.values()),
) )
void = uid # traçabilité future (réservé)
return int(cur.lastrowid) return int(cur.lastrowid)
except Exception: except Exception:
logger.exception("création page-support template") logger.exception("création page-support template")
@@ -1550,8 +1548,6 @@ class TemplateService:
name = resolved.get("__title__") or row["name"] name = resolved.get("__title__") or row["name"]
icon = row["icon"] or "📋" icon = row["icon"] or "📋"
description = row["description"] or "" description = row["description"] or ""
from app.services.db_templates import create_from_template
title = name title = name
uid = _actor_id(actor) uid = _actor_id(actor)
ccols = _cols(conn, "collections") ccols = _cols(conn, "collections")
@@ -1605,7 +1601,6 @@ class TemplateService:
) )
except Exception: except Exception:
logger.exception("seed row template base %s", row["id"]) logger.exception("seed row template base %s", row["id"])
void = create_from_template # compat : le helper historique reste l'API bas niveau
return collection_id return collection_id
@staticmethod @staticmethod
@@ -1614,7 +1609,6 @@ class TemplateService:
*, idempotency_key: str | None, run_source: str, *, idempotency_key: str | None, run_source: str,
) -> dict: ) -> dict:
"""Avant la migration 48 : instancie directement depuis les silos.""" """Avant la migration 48 : instancie directement depuis les silos."""
uid = _actor_id(actor)
if template_id.startswith("legacy_database:"): if template_id.startswith("legacy_database:"):
tid = int(template_id.split(":", 1)[1]) tid = int(template_id.split(":", 1)[1])
legacy = conn.execute( legacy = conn.execute(
+5 -1
View File
@@ -146,8 +146,12 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""} base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""}
try: try:
from app.services import draco_urls
headers = { headers = {
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)", # URL de contact = URL publique de l'instance, résolue par
# la config (FLOWDECK_URL/OAUTH_REDIRECT_URI ; défaut officiel).
"User-Agent": f"FlowDeck/5.5 bookmark-fetcher (+{draco_urls.flowdeck_url()})",
"Accept": "text/html,application/xhtml+xml", "Accept": "text/html,application/xhtml+xml",
} }
kwargs = {"timeout": timeout} kwargs = {"timeout": timeout}
+8 -5
View File
@@ -2,7 +2,10 @@
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4 Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution (ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
passe par une nouvelle version de commons. passe par une nouvelle version de commons.
CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js. #} CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js.
v7.82.0 (dev/prod) : les href des onglets ne sont plus figés —
l'hôte les injecte depuis sa config (draco_urls(), noms officiels
par défaut, noms en -dev déclarés dans le .env de l'environnement). #}
<header class="draco-bar" data-current="flowdeck"> <header class="draco-bar" data-current="flowdeck">
<div class="draco-bar-zone draco-bar-gauche"> <div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button> <button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
@@ -10,20 +13,20 @@
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span> <span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a> </a>
<nav class="draco-apptabs" aria-label="Applications"> <nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/"> <a class="draco-apptab" data-app="obsigate" href="{{ draco_urls().obsigate }}/">
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span> <span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span> <span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a> </a>
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/"> <a class="draco-apptab" data-app="flowdeck" href="{{ draco_urls().flowdeck }}/">
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span> <span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span> <span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a> </a>
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/"> <a class="draco-apptab" data-app="tiktak" href="{{ draco_urls().tiktak }}/">
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span> <span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span> <span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span> <span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a> </a>
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/"> <a class="draco-apptab" data-app="kanbante" href="{{ draco_urls().kanbante }}/">
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span> <span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span> <span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span> <span class="draco-badge" data-badge="kanbante" hidden>0</span>
+1
View File
@@ -533,4 +533,5 @@ body.fd-ap-resizing *{cursor:col-resize!important}
</div> </div>
</div> </div>
<script nonce="{{ csp_nonce() }}">window.FLOWDECK_TIKTAK_MCP_URL = {{ (draco_urls().tiktak + "/mcp") | tojson }};</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/agent_panel_2.js?v={{ asset_version }}"></script> <script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/agent_panel_2.js?v={{ asset_version }}"></script>
+5
View File
@@ -2871,6 +2871,11 @@
</script> </script>
<script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script> <script src="/static/js/app.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script src="/static/js/vendor/draco-barre.js?v={{ asset_version }}" defer data-cfasync="false"></script> <script src="/static/js/vendor/draco-barre.js?v={{ asset_version }}" defer data-cfasync="false"></script>
{# Panneau Spark vendangé (dracodev-commons) : contrat d'injection —
l'hôte fournit l'URL du service d'agents depuis SA config
(DRACO_AGENT_URL ; défaut = nom officiel, -dev via le .env).
Le JS ne contient plus aucun nom public en dur. #}
<script nonce="{{ csp_nonce() }}">window.DRACO_PANNEAU_CONFIG = { agent_url: {{ draco_agent_url() | tojson }} };</script>
<script src="/static/js/draco-panneau.js?v={{ asset_version }}" defer data-cfasync="false"></script> <script src="/static/js/draco-panneau.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<style> <style>
+1 -1
View File
@@ -72,7 +72,7 @@
{% if search %} {% if search %}
Aucun résultat pour "{{ search }}". Aucun résultat pour "{{ search }}".
{% else %} {% else %}
Créez un repo sur <a href="https://git.dracodev.net" style="color:var(--blue);">Gitea</a>. Créez un repo sur <a href="{{ gitea_url() }}" style="color:var(--blue);">Gitea</a>.
{% endif %} {% endif %}
</p> </p>
</div> </div>
+1 -1
View File
@@ -549,7 +549,7 @@
<div> <div>
<div class="setting-label">Gitea <span x-show="authMethod==='gitea'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div> <div class="setting-label">Gitea <span x-show="authMethod==='gitea'" style="font-size:10px;background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;margin-left:6px;">Primary</span></div>
<div class="setting-desc"> <div class="setting-desc">
<span x-show="giteaLinked">Connected to git.dracodev.net</span> <span x-show="giteaLinked">Connected to {{ gitea_url() }}</span>
<span x-show="!giteaLinked">Connect your Gitea account to access projects</span> <span x-show="!giteaLinked">Connect your Gitea account to access projects</span>
</div> </div>
</div> </div>
+12
View File
@@ -85,6 +85,12 @@ ENV.globals["plugin_enabled"] = lambda slug: _plugin_enabled(slug)
# liens de partage de pages (collaboration.py) : si elle est mal renseignée, # liens de partage de pages (collaboration.py) : si elle est mal renseignée,
# ces liens le sont déjà. # ces liens le sont déjà.
ENV.globals["app_base_url"] = lambda: _app_base_url() ENV.globals["app_base_url"] = lambda: _app_base_url()
# URLs publiques dracodev résolues par la config (dev/prod, v7.82.0) :
# l'hôte fournit les URL aux composants vendangés (barre commune,
# panneau Spark) — aucun nom public figé dans les templates/JS.
ENV.globals["draco_urls"] = lambda: _draco_urls()
ENV.globals["draco_agent_url"] = lambda: _draco_urls()["agent"]
ENV.globals["gitea_url"] = lambda: _draco_urls()["gitea"]
def _app_base_url() -> str: def _app_base_url() -> str:
@@ -93,6 +99,12 @@ def _app_base_url() -> str:
return (settings.app_base_url or "").rstrip("/") return (settings.app_base_url or "").rstrip("/")
def _draco_urls() -> dict:
from app.services import draco_urls
return draco_urls.toutes()
def _plugin_enabled(slug: str) -> bool: def _plugin_enabled(slug: str) -> bool:
from app.services.plugins import is_enabled from app.services.plugins import is_enabled
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "Capturez du contenu web directement dans FlowDeck", "description": "Capturez du contenu web directement dans FlowDeck",
"version": "1.0.0", "version": "1.0.0",
"permissions": ["activeTab", "storage", "scripting", "contextMenus"], "permissions": ["activeTab", "storage", "scripting", "contextMenus"],
"host_permissions": ["<all_urls>", "https://flowdeck.local/*", "https://flowdeck.dracodev.net/*", "http://localhost:8080/*", "http://localhost:3000/*"], "host_permissions": ["<all_urls>", "https://flowdeck.local/*", "https://flowdeck.dracodev.net/*", "https://flowdeck-dev.dracodev.net/*", "http://localhost:8080/*", "http://localhost:3000/*"],
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"
}, },
+143 -37
View File
@@ -1,83 +1,189 @@
/* Panneau Spark — branchement au service d'agents (Fusion, jalon J1). /* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
Composant d'intégration TikTak : le panneau définitif sera vendangé Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
depuis dracodev-commons ; celui-ci prouve le chemin panneau → service. */ le JS le cale sur le thème de l'app hôte (html[data-theme], repli
prefers-color-scheme) et suit les bascules en direct. Toutes les
couleurs passent par les variables ci-dessous — en-tête et fenêtre
partagent donc toujours le même mode. */
.draco-panneau { .draco-panneau {
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 94vw; --p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
background: #fbfbfa; color: #242624; z-index: 400; --p-texte: #242624; --p-doux: #6c7268; --p-faint: #8a8f84;
--p-chip: #f4f5f1; --p-chip-bord: #d6d8d1; --p-chip-survol: #e9ebe4;
--p-agent: #eef0ea; --p-champ: #ffffff; --p-code: #e8eae3;
--p-appro-fond: #fdf6ec; --p-danger-fond: #f6e3de; --p-danger: #a03a28;
--p-accent: #7c5cd6; --p-lien: #5b3df0;
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 96vw;
background: var(--p-fond); color: var(--p-texte); z-index: 400;
box-shadow: -12px 0 36px rgb(0 0 0 / .22); box-shadow: -12px 0 36px rgb(0 0 0 / .22);
display: flex; flex-direction: column; display: flex; flex-direction: column;
transform: translateX(105%); transition: transform .28s ease; transform: translateX(105%); transition: transform .28s ease;
font: 14px/1.5 system-ui, "Segoe UI", sans-serif; font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
} }
.draco-panneau[data-draco-theme="dark"] {
--p-fond: #17181a; --p-surface: #1f2023; --p-bord: #33353a;
--p-texte: #eceeeb; --p-doux: #a6aba0; --p-faint: #83887e;
--p-chip: #26272b; --p-chip-bord: #3a3c41; --p-chip-survol: #2e3036;
--p-agent: #26272b; --p-champ: #1f2023; --p-code: #2c2e33;
--p-appro-fond: #2a2118; --p-danger-fond: #40241f; --p-danger: #e8a08d;
--p-lien: #b39df5;
}
.draco-panneau.open { transform: translateX(0); } .draco-panneau.open { transform: translateX(0); }
.draco-poignee {
position: absolute; left: 0; top: 0; bottom: 0; width: 8px;
cursor: ew-resize; z-index: 5; touch-action: none;
}
.draco-poignee:hover { background: color-mix(in srgb, var(--p-accent) 35%, transparent); }
@media (max-width: 700px) { .draco-poignee { display: none; } }
.draco-panneau-hd { .draco-panneau-hd {
display: flex; align-items: center; gap: 9px; display: flex; align-items: center; gap: 9px;
padding: 13px 15px; border-bottom: 1px solid #e4e5e0; background: #fff; padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
} }
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; } .draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
.draco-panneau-hd .soustitre { font-size: 12px; color: #6c7268; } .draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
.draco-panneau-hd .fermer { .draco-panneau-hd .fermer {
margin-left: auto; border: 0; background: #eef0ea; border-radius: 8px; margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
width: 30px; height: 30px; cursor: pointer; font-size: 14px; border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
} }
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; } .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 .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-etat.ok .point { background: #2ea36b; } .draco-panneau-etat.ok .point { background: #2ea36b; }
.draco-panneau-etat.err .point { background: #cf4b32; } .draco-panneau-etat.err .point { background: #cf4b32; }
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid #e4e5e0; background: #fff; } /* Barre des discussions (façon Muse) */
.draco-panneau-outils button { .draco-disc-bar {
border: 1px solid #d6d8d1; background: #f4f5f1; border-radius: 999px; display: flex; align-items: center; gap: 7px;
padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer; padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
} }
.draco-panneau-outils button:hover { background: #e9ebe4; } .draco-disc-bar .d-titre {
flex: 1; font-weight: 600; font-size: 13px; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.draco-disc-bar .d-btn {
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
border-radius: 999px; padding: 4px 10px; font: 600 12px system-ui; cursor: pointer; flex: none;
}
.draco-disc-bar .d-btn:hover { background: var(--p-chip-survol); }
.draco-discussions {
border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
max-height: 46%; overflow-y: auto; padding: 8px 12px;
}
.draco-discussions .d-section {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .5px; color: var(--p-doux); margin: 8px 2px 4px;
}
.draco-discussions .d-section:first-child { margin-top: 0; }
.draco-discussions .d-item {
border: 1px solid transparent; border-radius: 9px; padding: 6px 8px; cursor: pointer;
}
.draco-discussions .d-item:hover { background: var(--p-chip); }
.draco-discussions .d-item.actif { border-color: var(--p-accent); background: var(--p-chip); }
.draco-discussions .d-meta { font-weight: 600; font-size: 13px; }
.draco-discussions .d-sous { font-size: 12px; color: var(--p-doux); }
.draco-discussions .d-actions { display: flex; gap: 6px; margin-top: 5px; }
.draco-discussions .d-actions button {
border: 1px solid var(--p-chip-bord); background: var(--p-surface); color: var(--p-texte);
border-radius: 7px; padding: 3px 9px; font: 600 11.5px system-ui; cursor: pointer;
}
.draco-discussions .d-actions button.danger { color: var(--p-danger); border-color: var(--p-danger-fond); }
.draco-discussions .d-vide, .draco-discussions .d-chargement {
font-size: 12.5px; color: var(--p-doux); padding: 2px 8px 6px;
}
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface); }
.draco-panneau-outils button {
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
}
.draco-panneau-outils button:hover { background: var(--p-chip-survol); }
.draco-appro { .draco-appro {
align-self: stretch; background: #fdf6ec; border: 1.6px solid #e8a13c; align-self: stretch; background: var(--p-appro-fond); border: 1.6px solid #e8a13c;
border-radius: 12px; padding: 10px 12px; border-radius: 12px; padding: 10px 12px;
} }
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; } .draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
.draco-panneau[data-draco-theme="dark"] .draco-appro .titre { color: #e8bc7a; }
.draco-appro .resume { margin: 5px 0 8px; } .draco-appro .resume { margin: 5px 0 8px; }
.draco-appro .btns { display: flex; gap: 7px; } .draco-appro .btns { display: flex; gap: 7px; }
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; } .draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
.draco-appro .btns button.go { background: #2ea36b; color: #fff; } .draco-appro .btns button.go { background: #2ea36b; color: #fff; }
.draco-auj { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; border-radius: 12px; padding: 10px 12px; } .draco-auj { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: #6c7268; margin: 6px 0 4px; } .draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 6px 0 4px; }
.draco-auj h5:first-child { margin-top: 0; } .draco-auj h5:first-child { margin-top: 0; }
.draco-auj ul { margin: 0; padding-left: 17px; } .draco-auj ul { margin: 0; padding-left: 17px; }
.draco-auj .etat-source { color: #6c7268; font-size: 12.5px; } .draco-auj .etat-source { color: var(--p-doux); font-size: 12.5px; }
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; } .draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; } .draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; } .draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
.draco-msg.agent { align-self: flex-start; background: #eef0ea; 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: #6c7268; 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 #e4e5e0; padding: 11px 12px; background: #fff; } .draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
.draco-panneau-form { display: flex; gap: 8px; } .draco-panneau-form { display: flex; gap: 8px; }
.draco-panneau-form input { .draco-panneau-form input {
flex: 1; border: 1px solid #d6d8d1; border-radius: 10px; padding: 9px 11px; font: inherit; flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
font: inherit; background: var(--p-champ); color: var(--p-texte);
} }
.draco-panneau-form button { .draco-panneau-form button {
border: 0; border-radius: 10px; background: #7c5cd6; color: #fff; border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer; padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
} }
.draco-panneau-form button:disabled { opacity: .5; cursor: default; } .draco-panneau-form button:disabled { opacity: .5; cursor: default; }
.draco-panneau-note { font-size: 11.5px; color: #8a8f84; margin-top: 8px; text-align: center; } .draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
.draco-missions { align-self: stretch; background: #fff; border: 1px solid #e4e5e0; 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: #6c7268; 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: #6c7268; margin: 8px 0 3px; } .draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
.draco-missions select, .draco-missions input, .draco-missions textarea { .draco-missions select, .draco-missions input, .draco-missions textarea {
width: 100%; box-sizing: border-box; border: 1px solid #d6d8d1; border-radius: 8px; width: 100%; box-sizing: border-box; border: 1px solid var(--p-chip-bord); border-radius: 8px;
padding: 7px 9px; font: inherit; font-size: 13px; background: #fff; padding: 7px 9px; font: inherit; font-size: 13px; background: var(--p-champ); color: var(--p-texte);
} }
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; } .draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: #eef0ea; } .draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
.draco-missions .btns button.go { background: #7c5cd6; color: #fff; } .draco-missions .btns button.go { background: var(--p-accent); color: #fff; }
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; } .draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
.draco-missions .etat-source { color: #6c7268; font-size: 12.5px; } .draco-missions .etat-source { color: var(--p-doux); font-size: 12.5px; }
.draco-missions .m-item { border: 1px solid #e4e5e0; border-radius: 10px; padding: 8px 10px; } .draco-missions .m-item { border: 1px solid var(--p-bord); border-radius: 10px; padding: 8px 10px; }
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; } .draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: #6c7268; white-space: nowrap; } .draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: var(--p-doux); white-space: nowrap; }
.draco-missions .m-statut.st-succeeded { color: #2ea36b; } .draco-missions .m-statut.st-succeeded { color: #2ea36b; }
.draco-missions .m-statut.st-failed { color: #cf4b32; } .draco-missions .m-statut.st-failed { color: #cf4b32; }
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; } .draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
.draco-missions .m-meta { font-size: 12px; color: #6c7268; } .draco-missions .m-meta { font-size: 12px; color: var(--p-doux); }
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; } .draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: #f6e3de; color: #a03a28; } .draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: var(--p-danger-fond); color: var(--p-danger); }
/* ── Rendu Markdown (HTML produit par le service) ──────────── */
.draco-msg.md, .draco-missions .m-rapport.md { white-space: normal; }
.draco-msg.md > *:first-child, .m-rapport.md > *:first-child { margin-top: 0; }
.draco-msg.md > *:last-child, .m-rapport.md > *:last-child { margin-bottom: 0; }
.draco-msg.md p, .m-rapport.md p { margin: 6px 0; }
.draco-msg.md h1, .draco-msg.md h2, .draco-msg.md h3,
.draco-msg.md h4, .draco-msg.md h5, .draco-msg.md h6 { margin: 10px 0 4px; line-height: 1.3; }
.draco-msg.md h1 { font-size: 16.5px; } .draco-msg.md h2 { font-size: 15.5px; }
.draco-msg.md h3 { font-size: 14.5px; } .draco-msg.md h4,
.draco-msg.md h5, .draco-msg.md h6 { font-size: 13.5px; }
.draco-msg.md ul, .draco-msg.md ol, .m-rapport.md ul, .m-rapport.md ol { margin: 6px 0; padding-left: 20px; }
.draco-msg.md li, .m-rapport.md li { margin: 2px 0; }
.draco-msg.md code, .m-rapport.md code {
background: var(--p-code); border-radius: 5px; padding: 1px 5px;
font: 12.5px ui-monospace, "Cascadia Mono", Consolas, monospace;
}
.draco-msg.md pre, .m-rapport.md pre {
background: var(--p-code); border-radius: 9px; padding: 9px 11px;
overflow-x: auto; margin: 7px 0;
}
.draco-msg.md pre code, .m-rapport.md pre code { background: none; padding: 0; }
.draco-msg.md blockquote, .m-rapport.md blockquote {
border-left: 3px solid var(--p-accent); margin: 7px 0; padding: 2px 0 2px 11px;
color: var(--p-doux);
}
.draco-msg.md blockquote.callout, .m-rapport.md blockquote.callout {
border-radius: 0 8px 8px 0; padding: 6px 11px; color: var(--p-texte);
background: var(--p-chip);
}
.draco-msg.md blockquote.callout-warning { border-left-color: #e8a13c; }
.draco-msg.md blockquote.callout-danger { border-left-color: #cf4b32; }
.draco-msg.md blockquote.callout-tip { border-left-color: #2ea36b; }
.draco-msg.md a, .m-rapport.md a { color: var(--p-lien); }
.draco-msg.md table, .m-rapport.md table {
border-collapse: collapse; margin: 7px 0; font-size: 12.5px;
display: block; overflow-x: auto; max-width: 100%;
}
.draco-msg.md th, .draco-msg.md td, .m-rapport.md th, .m-rapport.md td {
border: 1px solid var(--p-bord); padding: 4px 8px; text-align: left;
}
.draco-msg.md hr, .m-rapport.md hr { border: 0; border-top: 1px solid var(--p-bord); margin: 9px 0; }
.draco-msg.md input[type="checkbox"], .m-rapport.md input[type="checkbox"] { margin-right: 5px; }
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "Capturez du contenu web directement dans FlowDeck", "description": "Capturez du contenu web directement dans FlowDeck",
"version": "1.0.0", "version": "1.0.0",
"permissions": ["activeTab", "storage", "scripting", "contextMenus"], "permissions": ["activeTab", "storage", "scripting", "contextMenus"],
"host_permissions": ["<all_urls>", "https://flowdeck.local/*", "https://flowdeck.dracodev.net/*", "http://localhost:8080/*", "http://localhost:3000/*"], "host_permissions": ["<all_urls>", "https://flowdeck.local/*", "https://flowdeck.dracodev.net/*", "https://flowdeck-dev.dracodev.net/*", "http://localhost:8080/*", "http://localhost:3000/*"],
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"
}, },
+1 -1
View File
@@ -1424,7 +1424,7 @@
telegram:{url:'https://api.telegram.org/bot{secret}', auth:'none', telegram:{url:'https://api.telegram.org/bot{secret}', auth:'none',
hint:'BotFather → Bot Token ({secret} complète l\u2019URL)'}, hint:'BotFather → Bot Token ({secret} complète l\u2019URL)'},
mcp: {url:'', auth:'bearer', hint:'URL du endpoint MCP (streamable HTTP)'}, mcp: {url:'', auth:'bearer', hint:'URL du endpoint MCP (streamable HTTP)'},
tiktak: {url:'https://tiktak.dracodev.net/mcp', auth:'bearer', name:'TikTak', tiktak: {url:(window.FLOWDECK_TIKTAK_MCP_URL || 'https://tiktak.dracodev.net/mcp'), auth:'bearer', name:'TikTak',
hint:'TikTak → Réglages → Intégrations → clé API tck_…'} hint:'TikTak → Réglages → Intégrations → clé API tck_…'}
}; };
var p = presets[f.kind] || presets.custom; var p = presets[f.kind] || presets.custom;
+493 -47
View File
@@ -1,43 +1,269 @@
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → J5). /* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2).
* *
* Parle à l'API du service d'agents (contrat de dracodev-commons) : * Parle à l'API du service d'agents (contrat de dracodev-commons) :
* GET /api/v1/me — identité (cookie draco_session) * GET /api/v1/me — identité (cookie draco_session)
* POST /api/v1/chat — conversation réelle du noyau (J2) : * POST /api/v1/chat — conversation réelle du noyau (J2) :
* LLM DeepSeek + outils MCP des apps * LLM DeepSeek + outils MCP des apps ;
* GET /api/v1/conversations/… — historique de la conversation * la réponse arrive avec son HTML
* Markdown rendu côté service (v2)
* GET /api/v1/conversations — discussions façon Muse (v2) : une
* principale perpétuelle + des
* parallèles créées/archivées/
* supprimées par l'utilisateur
* GET /api/v1/conversations/… — historique d'une discussion (les
* messages agent portent leur html)
* GET /api/v1/executors — registre (noyau, sous-agents J3, * GET /api/v1/executors — registre (noyau, sous-agents J3,
* externes J4 : état de configuration) * externes J4 : état de configuration)
* POST /api/v1/missions — missions longues (J5) : lancement, * POST /api/v1/missions — missions longues (J5)
* suivi, rapport, annulation * Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
* (persistée), thème clair/sombre aligné sur l'app hôte
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
* Bouton « Console » (barre des discussions) : ouvre la console web
* du même service (URL injectée par l'hôte, voir plus bas) DANS la page, en surimpression
* pleine page (iframe) qui naît à la géométrie du panneau puis
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
* courante, ?app= app d'origine déduite du sous-domaine) est celle
* du premier chargement — l'iframe est créé paresseusement et
* conservé, la console garde son état entre ouvertures. La barre
* d'outils de la surimpression offre « Mode sidebar » (retour au
* panneau seul, animation inverse) et « Fermer l'agent » (referme
* surimpression et panneau).
* États honnêtes : service injoignable / session non reconnue / LLM non * États honnêtes : service injoignable / session non reconnue / LLM non
* configuré / exécuteur non configuré sont affichés tels quels. */ * configuré / exécuteur non configuré sont affichés tels quels. */
(function () { (function () {
"use strict"; "use strict";
var API = "https://agent.dracodev.net"; /* Contrat d'injection dracodev-commons (dev/prod) : l'app hôte
* fournit l'URL du service via window.DRACO_PANNEAU_CONFIG.agent_url
* (rendue depuis sa config serveur : DRACO_AGENT_URL, défaut = nom
* officiel ; un dev déclare son agent-dev dans le .env). Le repli
* ci-dessous n'est que le nom OFFICIEL, jamais un nom -dev. */
var API = ((window.DRACO_PANNEAU_CONFIG && window.DRACO_PANNEAU_CONFIG.agent_url) ||
"https://agent.dracodev.net").replace(/\/+$/, "");
var API_HOST = API.replace(/^https?:\/\//, "");
var convId = null; var convId = null;
try { convId = sessionStorage.getItem("draco_panneau_conv") || null; } catch (e) { /* stockage indispo */ } var convKind = null; // 'principale' | 'parallele'
var convTitre = "";
var etatConnu = false; var etatConnu = false;
var historiqueCharge = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null; var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null;
function domaine() { function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
} }
function stockerConv() {
try {
if (convId) sessionStorage.setItem("draco_panneau_conv", convId);
else sessionStorage.removeItem("draco_panneau_conv");
} catch (e) { /* stockage indisponible */ }
}
function convStockee() {
try { return sessionStorage.getItem("draco_panneau_conv") || null; }
catch (e) { return null; }
}
/* ── Console web (même service, surimpression pleine page) ─── */
function appHote() {
var h = (window.location.hostname || "").split(".")[0];
/* Dev/prod : les hôtes dev portent un suffixe -dev
* (flowdeck-dev, og-dev…) — on le retire avant correspondance. */
h = h.replace(/-dev$/, "");
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
kanbante: "kanbante", og: "obsigate" };
return correspondance[h] || null;
}
function urlConsole() {
var params = [];
var id = convId || convStockee();
if (id) params.push("disc=" + encodeURIComponent(id));
var app = appHote();
if (app) params.push("app=" + encodeURIComponent(app));
return API + "/" + (params.length ? "?" + params.join("&") : "");
}
/* La surimpression vit entièrement dans ce fichier (DOM + styles
* injectés) : les apps hôtes n'ont que ce fichier à recopier. Son
* thème suit celui du panneau (data-draco-theme synchronisé par
* appliquerTheme) et sa palette reprend les mêmes valeurs que
* draco-panneau.css. L'iframe est créé au premier clic puis
* conservé : rouvrir la console ne le recharge pas. */
var surEl = null, surCadre = null, surOuverte = false;
var STYLES_CONSOLE =
".draco-console-sur{" +
"--p-fond:#fbfbfa;--p-surface:#ffffff;--p-bord:#e4e5e0;--p-texte:#242624;" +
"--p-chip:#f4f5f1;--p-chip-bord:#d6d8d1;--p-chip-survol:#e9ebe4;--p-accent:#7c5cd6;" +
"position:fixed;top:0;right:0;height:100dvh;width:388px;max-width:100vw;z-index:1000;" +
"background:var(--p-fond);color:var(--p-texte);" +
"box-shadow:-12px 0 36px rgb(0 0 0 / .22);" +
"display:flex;flex-direction:column;transition:width .3s ease;" +
"font:14px/1.5 system-ui,\"Segoe UI\",sans-serif}" +
".draco-console-sur[data-draco-theme=\"dark\"]{" +
"--p-fond:#17181a;--p-surface:#1f2023;--p-bord:#33353a;--p-texte:#eceeeb;" +
"--p-chip:#26272b;--p-chip-bord:#3a3c41;--p-chip-survol:#2e3036}" +
".draco-console-sur[hidden]{display:none}" +
".draco-console-barre{display:flex;align-items:center;gap:9px;flex:none;" +
"padding:9px 12px;border-bottom:1px solid var(--p-bord);background:var(--p-surface)}" +
".draco-console-barre .dc-titre{font-weight:700;font-size:14px;" +
"display:flex;align-items:center;gap:8px;white-space:nowrap}" +
".draco-console-barre .dc-etoile{color:var(--p-accent);font-size:17px}" +
".draco-console-barre .dc-actions{margin-left:auto;display:flex;gap:7px}" +
".draco-console-barre button{border:1px solid var(--p-chip-bord);" +
"background:var(--p-chip);color:var(--p-texte);border-radius:999px;" +
"padding:5px 12px;font:600 12.5px system-ui;cursor:pointer;white-space:nowrap}" +
".draco-console-barre button:hover{background:var(--p-chip-survol)}" +
".draco-console-cadre{flex:1;width:100%;border:0;display:block;background:var(--p-fond)}";
function assurerSurimpression() {
if (surEl) return;
if (!document.getElementById("draco-console-styles")) {
var st = document.createElement("style");
st.id = "draco-console-styles";
st.textContent = STYLES_CONSOLE;
document.head.appendChild(st);
}
surEl = document.createElement("div");
surEl.className = "draco-console-sur";
surEl.setAttribute("aria-label", "Console Spark");
surEl.hidden = true;
surEl.innerHTML =
'<div class="draco-console-barre">' +
' <span class="dc-titre"><span class="dc-etoile">✦</span> Console Spark</span>' +
' <span class="dc-actions">' +
' <button type="button" data-dc="sidebar" title="Réduire la console et revenir au panneau latéral">◧ Mode sidebar</button>' +
' <button type="button" data-dc="fermer" title="Fermer la console et le panneau Spark">✕ Fermer l\'agent</button>' +
' </span></div>' +
'<iframe class="draco-console-cadre" title="Console Spark" allow="microphone"></iframe>';
document.body.appendChild(surEl);
surCadre = surEl.querySelector("iframe");
surEl.querySelector('[data-dc="sidebar"]')
.addEventListener("click", modeSidebarConsole);
surEl.querySelector('[data-dc="fermer"]')
.addEventListener("click", fermerAgentConsole);
appliquerTheme();
}
function largeurPanneau() {
if (root) {
var w = root.getBoundingClientRect().width;
if (w > 0) return Math.round(w);
}
return 388;
}
function ouvrirConsole() {
assurerSurimpression();
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
if (surOuverte) return;
surOuverte = true;
/* Naissance à la géométrie du panneau (ancrée à droite), puis
* extension à toute la page ; le double rAF laisse le temps de
* peindre la largeur de départ, sinon la transition ne joue pas. */
surEl.style.width = largeurPanneau() + "px";
surEl.hidden = false;
requestAnimationFrame(function () {
requestAnimationFrame(function () { surEl.style.width = "100%"; });
});
}
function retrecirConsole() {
if (!surEl || !surOuverte) return;
surOuverte = false;
var fini = false;
function terminer() {
if (fini) return;
fini = true;
surEl.removeEventListener("transitionend", surFin);
surEl.hidden = true; // l'iframe reste chargé, juste masqué
}
function surFin(ev) { if (ev.propertyName === "width") terminer(); }
surEl.addEventListener("transitionend", surFin);
setTimeout(terminer, 380); // filet si transitionend ne vient pas
surEl.style.width = largeurPanneau() + "px";
}
function modeSidebarConsole() { retrecirConsole(); }
function fermerAgentConsole() { fermer(); retrecirConsole(); }
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
function themeHote() {
var t = document.documentElement.getAttribute("data-theme");
if (t === "dark" || t === "light") return t;
if (document.documentElement.classList.contains("dark") ||
document.body.classList.contains("dark")) return "dark";
try {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
} catch (e) { /* pas de matchMedia */ }
return "light";
}
function appliquerTheme() {
if (root) root.setAttribute("data-draco-theme", themeHote());
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
}
function surveillerTheme() {
try {
new MutationObserver(appliquerTheme).observe(document.documentElement,
{ attributes: true, attributeFilter: ["data-theme", "class"] });
window.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", appliquerTheme);
} catch (e) { /* surveillance facultative */ }
}
/* ── Largeur réglable ──────────────────────────────────────── */
function largeurStockee() {
try {
var v = parseInt(localStorage.getItem("draco_panneau_largeur") || "", 10);
return isNaN(v) ? null : v;
} catch (e) { return null; }
}
function appliquerLargeur() {
if (!root) return;
var v = largeurStockee();
root.style.width = v ? Math.min(v, Math.round(window.innerWidth * 0.96)) + "px" : "";
}
function brancherPoignee(poignee) {
poignee.addEventListener("pointerdown", function (ev) {
if (window.innerWidth <= 700) return; // mobile : pleine largeur
ev.preventDefault();
var x0 = ev.clientX, w0 = root.getBoundingClientRect().width;
function bouger(e2) {
var w = Math.max(300, Math.min(760, w0 + (x0 - e2.clientX)));
root.style.width = w + "px";
}
function lacher(e2) {
document.removeEventListener("pointermove", bouger);
document.removeEventListener("pointerup", lacher);
var w = Math.round(root.getBoundingClientRect().width);
try { localStorage.setItem("draco_panneau_largeur", String(w)); } catch (e) { /* ok */ }
}
document.addEventListener("pointermove", bouger);
document.addEventListener("pointerup", lacher);
});
poignee.addEventListener("dblclick", function () {
try { localStorage.removeItem("draco_panneau_largeur"); } catch (e) { /* ok */ }
appliquerLargeur();
});
}
function construire() { function construire() {
root = document.createElement("aside"); root = document.createElement("aside");
root.className = "draco-panneau"; root.className = "draco-panneau";
root.setAttribute("aria-label", "Spark, l'agent commun"); root.setAttribute("aria-label", "Spark, l'agent commun");
root.innerHTML = root.innerHTML =
'<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><div class="titre">Spark — agent commun</div>' +
' <div class="soustitre">Service d\'agents dracodev</div></div>' + ' <div class="soustitre">Service d\'agents dracodev</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="border-bottom:1px solid #e4e5e0;padding-top:8px;padding-bottom:8px">' + '<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>' + ' <span class="point"></span><span class="txt">Connexion au service…</span>' +
'</div>' + '</div>' +
'<div class="draco-disc-bar">' +
' <span class="d-titre">Discussion principale</span>' +
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (" + API_HOST + ") en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
'</div>' +
'<div class="draco-discussions" hidden></div>' +
'<div class="draco-panneau-outils">' + '<div class="draco-panneau-outils">' +
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' + ' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
' <button type="button" data-outil="approbations">Approbations</button>' + ' <button type="button" data-outil="approbations">Approbations</button>' +
@@ -54,15 +280,30 @@
etatEl = root.querySelector(".draco-panneau-etat"); etatEl = root.querySelector(".draco-panneau-etat");
inputEl = root.querySelector("input"); inputEl = root.querySelector("input");
sendBtn = root.querySelector("button[type=submit]"); sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions");
root.querySelector(".fermer").addEventListener("click", fermer); root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
root.querySelector('[data-disc="console"]').addEventListener("click", ouvrirConsole);
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui); root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
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);
brancherPoignee(root.querySelector(".draco-poignee"));
appliquerTheme();
surveillerTheme();
appliquerLargeur();
window.addEventListener("resize", appliquerLargeur);
document.addEventListener("keydown", function (ev) { document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && root.classList.contains("open")) fermer(); if (ev.key !== "Escape") return;
/* Surimpression ouverte : Échap = retour au panneau seul
* (« Mode sidebar »). Quand le focus est dans l'iframe de la
* console (autre origine), l'événement ne remonte pas
* jusqu'ici : il ne peut donc pas fermer par surprise. */
if (surOuverte) { modeSidebarConsole(); return; }
if (root.classList.contains("open")) fermer();
}); });
ajouterMsg("sys", "Conversation avec le service d'agents — domaine : " + domaine() + ".");
} }
function ajouterMsg(kind, texte) { function ajouterMsg(kind, texte) {
@@ -74,10 +315,26 @@
return d; return d;
} }
/* Message agent rendu en Markdown : le HTML vient du service
* (rendu + sanitizer côté serveur) ; repli texte si absent. */
function ajouterMsgMd(texte, html) {
var d = document.createElement("div");
d.className = "draco-msg agent md";
if (html) d.innerHTML = html;
else d.textContent = texte;
msgsEl.appendChild(d);
msgsEl.scrollTop = msgsEl.scrollHeight;
return d;
}
function majEtat(cls, texte) { function majEtat(cls, texte) {
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls; etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
etatEl.querySelector(".txt").textContent = texte; etatEl.querySelector(".txt").textContent = texte;
} }
function majTitre() {
titreEl.textContent = convTitre || "Discussion principale";
titreEl.title = titreEl.textContent;
}
function verifierIdentite() { function verifierIdentite() {
if (etatConnu) return; if (etatConnu) return;
@@ -99,7 +356,7 @@
}) })
.catch(function () { .catch(function () {
majEtat("err", "Service d'agents injoignable"); majEtat("err", "Service d'agents injoignable");
ajouterMsg("sys", "Le service d'agents (agent.dracodev.net) ne répond pas. Vérifie que le conteneur dracodev-agent est démarré."); ajouterMsg("sys", "Le service d'agents (" + API_HOST + ") ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
}); });
} }
@@ -109,6 +366,200 @@
}); });
} }
/* ── Discussions (façon Muse) ────────────────────────────── */
function chargerListeDiscussions() {
return fetch(API + "/api/v1/conversations?domain=" + encodeURIComponent(domaine()),
{ credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
}
function demarrerDiscussions() {
chargerListeDiscussions()
.then(function (d) {
var precedente = convStockee();
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
var active = trouvee || d.principale;
if (!active) return;
convId = active.id; convKind = active.kind; convTitre = active.title;
stockerConv(); majTitre();
chargerHistorique();
})
.catch(function () {
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
});
}
function basculerDiscussion(c) {
convId = c.id; convKind = c.kind; convTitre = c.title;
stockerConv(); majTitre();
tiroirEl.hidden = true;
msgsEl.innerHTML = "";
ajouterMsg("sys", (c.kind === "principale"
? "Discussion principale — le fil sans fin."
: "Discussion parallèle : " + c.title) + " · domaine : " + domaine() + ".");
chargerHistorique();
inputEl.focus();
}
function nouvelleDiscussion() {
fetch(API + "/api/v1/conversations", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain: domaine() })
})
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (c) {
basculerDiscussion(c);
ajouterMsg("sys", "Nouvelle discussion parallèle créée — elle prendra le titre de ton premier message.");
})
.catch(function () { ajouterMsg("sys", "Création impossible (service injoignable ?)."); });
}
function actionDiscussion(id, action) {
var methode = action === "supprimer" ? "DELETE" : "POST";
var url = API + "/api/v1/conversations/" + id + (action === "supprimer" ? "" : "/" + action);
return fetch(url, { method: methode, credentials: "include" })
.then(function (r) {
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
});
return r.json();
});
}
function ligneDiscussion(c, actions) {
var ligne = document.createElement("div");
ligne.className = "d-item" + (c.id === convId ? " actif" : "");
var meta = document.createElement("div");
meta.className = "d-meta";
var quand = "";
try {
quand = new Date(c.updated_at * 1000).toLocaleDateString("fr-CA",
{ day: "numeric", month: "short" });
} catch (e) { /* date illisible */ }
meta.textContent = (c.kind === "principale" ? "📌 " : "") + c.title;
ligne.appendChild(meta);
var sous = document.createElement("div");
sous.className = "d-sous";
sous.textContent = c.nb_messages + " message(s)" + (quand ? " · " + quand : "") +
(c.kind === "principale" ? " · perpétuelle" : "");
ligne.appendChild(sous);
meta.addEventListener("click", function () { basculerDiscussion(c); });
sous.addEventListener("click", function () { basculerDiscussion(c); });
if (actions && actions.length) {
var btns = document.createElement("div");
btns.className = "d-actions";
actions.forEach(function (a) {
var b = document.createElement("button");
b.type = "button"; b.textContent = a.label;
if (a.danger) b.className = "danger";
b.addEventListener("click", function (ev) {
ev.stopPropagation();
if (a.confirmer && !window.confirm(a.confirmer)) return;
actionDiscussion(c.id, a.action)
.then(function () {
if (c.id === convId && a.action !== "desarchiver") {
chargerListeDiscussions().then(function (d) {
if (d.principale) {
basculerDiscussion(d.principale);
ajouterMsg("sys", a.action === "supprimer"
? "Discussion supprimée — retour à la discussion principale."
: "Discussion archivée — retour à la discussion principale.");
}
});
}
remplirTiroir();
})
.catch(function (e) { ajouterMsg("sys", "Action refusée : " + e.message); });
});
btns.appendChild(b);
});
ligne.appendChild(btns);
}
return ligne;
}
function remplirTiroir() {
tiroirEl.innerHTML = "<div class='d-chargement'>Chargement des discussions…</div>";
chargerListeDiscussions()
.then(function (d) {
tiroirEl.innerHTML = "";
var h = document.createElement("div");
h.className = "d-section";
h.textContent = "Discussion principale";
tiroirEl.appendChild(h);
if (d.principale) tiroirEl.appendChild(ligneDiscussion(d.principale, []));
h = document.createElement("div");
h.className = "d-section";
h.textContent = "Discussions parallèles";
tiroirEl.appendChild(h);
if (!d.paralleles.length) {
var vide = document.createElement("div");
vide.className = "d-vide";
vide.textContent = "Aucune pour le moment — bouton « + Nouvelle ».";
tiroirEl.appendChild(vide);
}
d.paralleles.forEach(function (c) {
tiroirEl.appendChild(ligneDiscussion(c, [
{ label: "Archiver", action: "archive" },
{ label: "Supprimer", action: "supprimer", danger: true,
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
]));
});
if (d.archivees && d.archivees.length) {
h = document.createElement("div");
h.className = "d-section";
h.textContent = "Archivées";
tiroirEl.appendChild(h);
d.archivees.forEach(function (c) {
tiroirEl.appendChild(ligneDiscussion(c, [
{ label: "Désarchiver", action: "desarchiver" },
{ label: "Supprimer", action: "supprimer", danger: true,
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
]));
});
}
})
.catch(function () {
tiroirEl.innerHTML = "<div class='d-chargement'>Discussions : service injoignable.</div>";
});
}
function basculerTiroir() {
tiroirEl.hidden = !tiroirEl.hidden;
if (!tiroirEl.hidden) remplirTiroir();
}
function chargerHistorique() {
if (!convId) return;
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
.then(function (r) {
if (r.status === 404) { convId = null; stockerConv(); return null; }
if (!r.ok) return null;
return r.json();
})
.then(function (d) {
if (!d) return;
convKind = d.kind; convTitre = d.title || convTitre;
majTitre();
if (!msgsEl.querySelector(".draco-msg")) {
ajouterMsg("sys", (d.kind === "principale"
? "Discussion principale — le fil sans fin."
: "Discussion parallèle : " + (d.title || "")) + " · domaine : " + domaine() + ".");
}
var sys = msgsEl.querySelector(".draco-msg.sys");
(d.messages || []).forEach(function (m) {
var el = m.role === "user"
? ajouterMsg("user", m.content)
: ajouterMsgMd(m.content, m.html);
if (sys) { msgsEl.insertBefore(el, sys.nextSibling); sys = el; }
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { /* historique indisponible : on repart à neuf */ });
}
function chargerAujourdhui() { function chargerAujourdhui() {
var bloc = document.createElement("div"); var bloc = document.createElement("div");
bloc.className = "draco-auj"; bloc.className = "draco-auj";
@@ -178,7 +629,7 @@
.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(); })
.then(function (d) { .then(function (d) {
bloc.style.opacity = ".55"; bloc.style.opacity = ".55";
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px;color:#6c7268'>Décision enregistrée : " + esc(d.status) + "</span>"; bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px'>Décision enregistrée : " + esc(d.status) + "</span>";
}) })
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); }); .catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
} }
@@ -239,12 +690,24 @@
d.missions.slice(0, 10).forEach(function (m) { d.missions.slice(0, 10).forEach(function (m) {
var ligne = document.createElement("div"); var ligne = document.createElement("div");
ligne.className = "m-item"; ligne.className = "m-item";
var rapport = m.result ? "<div class='m-rapport'>" + esc(String(m.result).slice(0, 700)) + "</div>" : ""; var entete = document.createElement("div");
ligne.innerHTML = "<div class='m-entete'><b>" + esc(m.title) + "</b>" + entete.className = "m-entete";
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span></div>" + entete.innerHTML = "<b>" + esc(m.title) + "</b>" +
"<div class='m-meta'>" + esc(m.executor) + "<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
(m.meta && m.meta.duree_s ? " · " + esc(m.meta.duree_s) + " s" : "") + ligne.appendChild(entete);
(m.meta && m.meta.cout ? " · coût : " + esc(m.meta.cout) : "") + "</div>" + rapport; var meta = document.createElement("div");
meta.className = "m-meta";
meta.textContent = m.executor +
(m.meta && m.meta.duree_s ? " · " + m.meta.duree_s + " s" : "") +
(m.meta && m.meta.cout ? " · coût : " + m.meta.cout : "");
ligne.appendChild(meta);
if (m.result) {
var rapport = document.createElement("div");
rapport.className = "m-rapport md";
if (m.result_html) rapport.innerHTML = m.result_html;
else rapport.textContent = String(m.result).slice(0, 1200);
ligne.appendChild(rapport);
}
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") { if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
var btn = document.createElement("button"); var btn = document.createElement("button");
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler"; btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
@@ -290,31 +753,6 @@
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); }); .catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
} }
function chargerHistorique() {
if (historiqueCharge || !convId) return;
historiqueCharge = true;
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
.then(function (r) {
if (r.status === 404) {
convId = null;
try { sessionStorage.removeItem("draco_panneau_conv"); } catch (e) { /* ok */ }
return null;
}
if (!r.ok) return null;
return r.json();
})
.then(function (d) {
if (!d || !d.messages || !d.messages.length) return;
var sys = msgsEl.querySelector(".draco-msg.sys");
d.messages.forEach(function (m) {
var el = ajouterMsg(m.role === "user" ? "user" : "agent", m.content);
if (sys) msgsEl.insertBefore(el, sys.nextSibling), sys = el;
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { /* historique indisponible : la conversation repart à neuf */ });
}
function envoyer(ev) { function envoyer(ev) {
ev.preventDefault(); ev.preventDefault();
var texte = inputEl.value.trim(); var texte = inputEl.value.trim();
@@ -347,8 +785,16 @@
.then(function (d) { .then(function (d) {
if (!d) return; if (!d) return;
convId = d.conversation_id; convId = d.conversation_id;
try { sessionStorage.setItem("draco_panneau_conv", convId); } catch (e) { /* ok */ } convKind = d.kind || convKind;
if (d.title) convTitre = d.title;
stockerConv(); majTitre();
if (d.html) {
attente.className = "draco-msg agent md";
attente.innerHTML = d.html;
} else {
attente.textContent = d.reply; attente.textContent = d.reply;
}
msgsEl.scrollTop = msgsEl.scrollHeight;
if (d.tools_used && d.tools_used.length) { if (d.tools_used && d.tools_used.length) {
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?")); ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
} }
@@ -362,10 +808,10 @@
} }
function ouvrir() { function ouvrir() {
if (!root) construire(); if (!root) { construire(); demarrerDiscussions(); }
root.classList.add("open"); root.classList.add("open");
appliquerTheme();
verifierIdentite(); verifierIdentite();
chargerHistorique();
setTimeout(function () { inputEl.focus(); }, 300); setTimeout(function () { inputEl.focus(); }, 300);
} }
function fermer() { if (root) root.classList.remove("open"); } function fermer() { if (root) root.classList.remove("open"); }
+9
View File
@@ -24,6 +24,7 @@ import contextlib
import ipaddress import ipaddress
import json import json
import socket import socket
import sys
import httpx import httpx
import pytest import pytest
@@ -108,6 +109,14 @@ def mock_http(handler):
from app.services.importers import url_fetch from app.services.importers import url_fetch
if getattr(url_fetch, "shared_client", None) is build: if getattr(url_fetch, "shared_client", None) is build:
url_fetch.shared_client = _REAL_SHARED_CLIENT url_fetch.shared_client = _REAL_SHARED_CLIENT
# Même fuite via og_fetcher : url_fetch l'importe paresseusement
# (parse_og) PENDANT la fenêtre, et son `from … import shared_client`
# fige alors `build` — le handler du test courant répondait ensuite
# aux fetch_og_metadata des tests suivants du même worker xdist
# (flake test_v55 : titre « Titre de la page » au lieu du sien).
og_fetcher = sys.modules.get("app.services.og_fetcher")
if og_fetcher is not None and getattr(og_fetcher, "shared_client", None) is build:
og_fetcher.shared_client = _REAL_SHARED_CLIENT
def run(coro): def run(coro):
+124
View File
@@ -0,0 +1,124 @@
"""URLs publiques dracodev par configuration — chantier dev/prod (v7.82.0).
Règle vérifiée : aucun nom public codé en dur dans le code vivant ;
les défauts sont les noms OFFICIELS (jamais de ``-dev`` en défaut) et
chaque environnement déclare ses noms en ``-dev`` par l'environnement
(``DRACO_SSO_URL``, ``DRACO_AGENT_URL``, ``TIKTAK_URL``,
``KANBANTE_URL``, ``FLOWDECK_URL``/``OAUTH_REDIRECT_URI``…).
"""
import pytest
from app.services import draco_urls
VARS = ("DRACO_SSO_URL", "DRACO_AGENT_URL", "TIKTAK_URL", "KANBANTE_URL",
"FLOWDECK_URL", "OAUTH_REDIRECT_URI", "AGENT_EVENEMENTS_URL")
@pytest.fixture(autouse=True)
def _env_propre(monkeypatch):
for v in VARS:
monkeypatch.delenv(v, raising=False)
def test_defauts_officiels_sans_dev():
urls = draco_urls.toutes()
assert urls["obsigate"] == "https://og.dracodev.net"
assert urls["agent"] == "https://agent.dracodev.net"
assert urls["tiktak"] == "https://tiktak.dracodev.net"
assert urls["kanbante"] == "https://kanbante.dracodev.net"
assert urls["flowdeck"] == "https://flowdeck.dracodev.net"
assert all("-dev" not in u for u in urls.values())
def test_surcharge_dev_par_env(monkeypatch):
monkeypatch.setenv("DRACO_SSO_URL", "https://og-dev.dracodev.net")
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
monkeypatch.setenv("TIKTAK_URL", "https://tiktak-dev.dracodev.net")
monkeypatch.setenv("KANBANTE_URL", "https://kanbante-dev.dracodev.net")
monkeypatch.setenv("FLOWDECK_URL", "https://flowdeck-dev.dracodev.net")
urls = draco_urls.toutes()
assert urls["obsigate"] == "https://og-dev.dracodev.net"
assert urls["agent"] == "https://agent-dev.dracodev.net"
assert urls["tiktak"] == "https://tiktak-dev.dracodev.net"
assert urls["kanbante"] == "https://kanbante-dev.dracodev.net"
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
def test_flowdeck_url_derivée_oauth_redirect(monkeypatch):
monkeypatch.setenv(
"OAUTH_REDIRECT_URI", "https://flowdeck-dev.dracodev.net/auth/callback")
assert draco_urls.flowdeck_url() == "https://flowdeck-dev.dracodev.net"
def test_evenements_url_derivée_agent(monkeypatch):
from app.services import agent_evenements
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
assert agent_evenements._url() == \
"https://agent-dev.dracodev.net/api/v1/evenements"
monkeypatch.setenv("AGENT_EVENEMENTS_URL", "http://127.0.0.1:9/api/v1/evenements")
assert agent_evenements._url() == "http://127.0.0.1:9/api/v1/evenements"
def test_csp_agent_depuis_config(client, monkeypatch):
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
r = client.get("/")
csp = r.headers.get("content-security-policy", "")
assert "https://agent-dev.dracodev.net" in csp
assert "connect-src 'self' https://agent-dev.dracodev.net" in csp
assert "frame-src 'self' https://agent-dev.dracodev.net" in csp
assert "https://agent.dracodev.net " not in csp.replace(
"https://agent-dev.dracodev.net", "")
def test_parse_deep_link_dev_et_officiel(monkeypatch):
from app.services.external_refs import parse_deep_link
monkeypatch.setenv("TIKTAK_URL", "https://tiktak-dev.dracodev.net")
monkeypatch.setenv("DRACO_SSO_URL", "https://og-dev.dracodev.net")
# Hôte configuré (dev)…
assert parse_deep_link("https://tiktak-dev.dracodev.net/?task=7")[:3] == \
("tiktak", "task", "7")
assert parse_deep_link("https://og-dev.dracodev.net/#file=P:A.md")[:3] == \
("obsigate", "note", "P:A.md")
# …et hôte officiel toujours reconnu (références déjà stockées).
assert parse_deep_link("https://tiktak.dracodev.net/?task=7")[:3] == \
("tiktak", "task", "7")
assert parse_deep_link("https://og.dracodev.net/#file=P:A.md")[:3] == \
("obsigate", "note", "P:A.md")
def test_url_affichage_reecrit_hote_sans_toucher_stockage(monkeypatch):
from app.services.external_refs import url_affichage
monkeypatch.setenv("TIKTAK_URL", "https://tiktak-dev.dracodev.net")
stockee = "https://tiktak.dracodev.net/?task=42"
assert url_affichage(stockee) == "https://tiktak-dev.dracodev.net/?task=42"
# La fonction est pure : la valeur d'entrée (celle en base) ne bouge pas.
assert stockee == "https://tiktak.dracodev.net/?task=42"
assert url_affichage("https://example.com/x") == "https://example.com/x"
def test_preset_tiktak_depuis_config(monkeypatch):
from app.services import connectors
assert connectors.PRESETS["tiktak"]["url"] == \
"https://tiktak.dracodev.net/mcp"
monkeypatch.setenv("TIKTAK_URL", "https://tiktak-dev.dracodev.net")
assert connectors.PRESETS["tiktak"]["url"] == \
"https://tiktak-dev.dracodev.net/mcp"
def test_bouton_sso_login_depuis_config(monkeypatch):
from app.routers.auth import _sso_login_url
assert _sso_login_url() == (
"https://og.dracodev.net/sso/authorize?return_to="
"https%3A%2F%2Fflowdeck.dracodev.net%2Fauth%2Fdraco")
monkeypatch.setenv("DRACO_SSO_URL", "https://og-dev.dracodev.net")
monkeypatch.setenv(
"OAUTH_REDIRECT_URI", "https://flowdeck-dev.dracodev.net/auth/callback")
assert _sso_login_url() == (
"https://og-dev.dracodev.net/sso/authorize?return_to="
"https%3A%2F%2Fflowdeck-dev.dracodev.net%2Fauth%2Fdraco")
+282
View File
@@ -0,0 +1,282 @@
"""Émission d'événements vers le journal du service d'agents
(Fusion, Lot 2 — contrat ACC-0.2).
Un faux récepteur local capture les POST signés HMAC et les vérifie
comme le fait le service (journal.verifier_signature) ; on prouve
aussi l'inactivité sans clé et la non-régression métier quand le
récepteur est en panne. Surfaces couvertes : API de session (board
et collections), API publique v2 et MCP (acteur « agent:mcp »).
"""
import hashlib
import hmac
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from app.db import get_conn
CLE = "cle-flowdeck-de-test"
class _Recepteur(BaseHTTPRequestHandler):
recus = []
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
corps = self.rfile.read(n)
type(self).recus.append(
{"headers": {k.lower(): v for k, v in self.headers.items()},
"corps": corps})
self.send_response(201)
self.end_headers()
def log_message(self, *a):
pass
def _demarrer(monkeypatch):
_Recepteur.recus = []
serveur = HTTPServer(("127.0.0.1", 0), _Recepteur)
port = serveur.server_address[1]
threading.Thread(target=serveur.serve_forever, daemon=True).start()
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
f"http://127.0.0.1:{port}/api/v1/evenements")
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
return serveur
def _attendre(nombre, delai=6.0):
"""Attend `nombre` événements ; vérifie la signature HMAC de
chacun (comme le service) et retourne la liste des enveloppes."""
fin = time.time() + delai
while time.time() < fin and len(_Recepteur.recus) < nombre:
time.sleep(0.05)
assert len(_Recepteur.recus) >= nombre, \
f"{len(_Recepteur.recus)} événement(s) reçu(s), {nombre} attendus"
envs = []
for recu in _Recepteur.recus:
sig = recu["headers"].get("x-draco-signature", "")
attendu = hmac.new(CLE.encode(), recu["corps"],
hashlib.sha256).hexdigest()
assert hmac.compare_digest(sig.lower(), attendu), \
"signature HMAC invalide"
env = json.loads(recu["corps"].decode("utf-8"))
assert env["source_app"] == "flowdeck"
assert env["id"] and env["occurred_at"]
envs.append(env)
return envs
def _par_type(envs, type_):
return [e for e in envs if e["type"] == type_]
def test_enveloppe_et_signature():
from app.services import agent_evenements
env, corps = agent_evenements.construire_enveloppe(
"flowdeck.page.created", "page:42", "Page de test",
url="/board/pages/42", actor="user")
assert env["source_app"] == "flowdeck"
assert env["domain"] == "pro"
assert env["actor"] == "user"
assert env["object"] == {"ref": "page:42", "label": "Page de test",
"url": "/board/pages/42"}
assert json.loads(corps.decode("utf-8")) == env
sig = hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
assert len(sig) == 64
def test_cycle_de_vie_api_session(client, monkeypatch):
serveur = _demarrer(monkeypatch)
try:
# Page d'espace de travail (board) : création, màj, suppression.
r = client.post("/board/api/pages", params={"title": "Page journal"})
assert r.status_code == 200, r.text
page_id = r.json()["id"]
r = client.put(f"/board/api/pages/{page_id}",
params={"title": "Page journal (éditée)"})
assert r.status_code == 200, r.text
r = client.delete(f"/board/api/pages/{page_id}")
assert r.status_code == 200, r.text
# Collection puis sa page : création, màj, suppression.
r = client.post("/db/api", json={"name": "Collection journal"})
assert r.status_code == 200, r.text
cid = r.json()["id"]
r = client.post(f"/db/{cid}/pages/api",
json={"title": "Ligne journal"})
assert r.status_code == 200, r.text
ligne_id = r.json()["id"]
r = client.put(f"/db/pages/{ligne_id}/api",
json={"title": "Ligne journal (éditée)"})
assert r.status_code == 200, r.text
r = client.delete(f"/db/pages/{ligne_id}/api")
assert r.status_code == 200, r.text
envs = _attendre(7)
crees = _par_type(envs, "flowdeck.page.created")
assert len(crees) == 2
urls = {e["object"]["url"] for e in crees}
assert f"/board/pages/{page_id}" in urls
assert f"/db/{cid}/view/table" in urls
board = next(e for e in crees
if e["object"]["url"] == f"/board/pages/{page_id}")
assert board["object"]["ref"] == f"page:{page_id}"
assert board["object"]["label"] == "Page journal"
assert board["actor"] == "user"
maj = _par_type(envs, "flowdeck.page.updated")
assert len(maj) == 2
labels = {e["object"]["label"] for e in maj}
assert labels == {"Page journal (éditée)", "Ligne journal (éditée)"}
suppr = _par_type(envs, "flowdeck.page.deleted")
assert len(suppr) == 2
refs = {e["object"]["ref"] for e in suppr}
assert refs == {f"page:{page_id}", f"page:{ligne_id}"}
coll = _par_type(envs, "flowdeck.collection.created")
assert len(coll) == 1
assert coll[0]["object"] == {
"ref": f"collection:{cid}", "label": "Collection journal",
"url": f"/db/{cid}/view/table"}
assert coll[0]["actor"] == "user"
finally:
serveur.shutdown()
def test_api_publique_v2(client, monkeypatch):
serveur = _demarrer(monkeypatch)
try:
r = client.post("/auth/register", json={
"email": "[email protected]", "password": "secret123",
"name": "journal"})
assert r.status_code == 200, r.text
tok = client.post("/api/v1/token").json()["token"]
h1 = {"Authorization": f"Bearer {tok}"}
r = client.post("/api/v2/tokens",
json={"name": "ci", "scopes": "read,write"},
headers=h1)
assert r.status_code == 200, r.text
h = {"Authorization": f"Bearer {r.json()['token']}"}
r = client.post("/api/v2/collections",
json={"name": "Collection v2"}, headers=h)
assert r.status_code == 201, r.text
cid = r.json()["id"]
r = client.post(f"/api/v2/collections/{cid}/pages",
json={"title": "Page v2"}, headers=h)
assert r.status_code == 201, r.text
pid = r.json()["id"]
r = client.patch(f"/api/v2/pages/{pid}",
json={"title": "Page v2 (éditée)"}, headers=h)
assert r.status_code == 200, r.text
r = client.delete(f"/api/v2/pages/{pid}", headers=h)
assert r.status_code == 200, r.text
envs = _attendre(4)
types = [e["type"] for e in envs]
assert types.count("flowdeck.collection.created") == 1
assert types.count("flowdeck.page.created") == 1
assert types.count("flowdeck.page.updated") == 1
assert types.count("flowdeck.page.deleted") == 1
assert all(e["actor"] == "user" for e in envs)
coll = _par_type(envs, "flowdeck.collection.created")[0]
assert coll["object"]["ref"] == f"collection:{cid}"
page = _par_type(envs, "flowdeck.page.created")[0]
assert page["object"]["ref"] == f"page:{pid}"
assert page["object"]["url"] == f"/db/{cid}/view/table"
finally:
serveur.shutdown()
@pytest.fixture()
def jeton_mcp(client):
brut = "fd_test_token_journal"
with get_conn() as conn:
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, revoked) "
"VALUES (1, 'service-agents', ?, ?, 0)",
(hashlib.sha256(brut.encode()).hexdigest(), brut[:12]))
conn.commit()
return brut
def _appel_mcp(client, jeton, rid, nom, args=None):
r = client.post(
"/mcp",
json={"jsonrpc": "2.0", "id": rid, "method": "tools/call",
"params": {"name": nom, "arguments": args or {}}},
headers={"Authorization": f"Bearer {jeton}"})
assert r.status_code == 200, r.text
return json.loads(r.json()["result"]["content"][0]["text"])
def test_mcp_acteur_agent(client, monkeypatch, jeton_mcp):
serveur = _demarrer(monkeypatch)
try:
coll = _appel_mcp(client, jeton_mcp, 1, "create_collection",
{"name": "Collection MCP"})
cid = coll["id"]
page = _appel_mcp(client, jeton_mcp, 2, "create_page",
{"collection_id": cid, "title": "Page MCP"})
pid = page["id"]
_appel_mcp(client, jeton_mcp, 3, "update_page",
{"id": pid, "title": "Page MCP (éditée)"})
envs = _attendre(3)
assert all(e["actor"] == "agent:mcp" for e in envs)
coll_env = _par_type(envs, "flowdeck.collection.created")
assert len(coll_env) == 1
assert coll_env[0]["object"] == {
"ref": f"collection:{cid}", "label": "Collection MCP",
"url": f"/db/{cid}/view/table"}
creee = _par_type(envs, "flowdeck.page.created")
assert len(creee) == 1
assert creee[0]["object"]["ref"] == f"page:{pid}"
assert creee[0]["object"]["label"] == "Page MCP"
maj = _par_type(envs, "flowdeck.page.updated")
assert len(maj) == 1
assert maj[0]["object"]["label"] == "Page MCP (éditée)"
finally:
serveur.shutdown()
def test_inactif_sans_cle(client, monkeypatch):
serveur = _demarrer(monkeypatch)
monkeypatch.delenv("AGENT_EVENEMENTS_CLE")
try:
from app.services import agent_evenements
assert agent_evenements.actif() is False
assert agent_evenements.emettre(
"flowdeck.page.created", "page:1", "x") is False
r = client.post("/board/api/pages", params={"title": "Sans journal"})
assert r.status_code == 200, r.text
r = client.post("/db/api", json={"name": "Collection sans journal"})
assert r.status_code == 200, r.text
time.sleep(0.6)
assert _Recepteur.recus == []
finally:
serveur.shutdown()
def test_recepteur_en_panne_ne_casse_rien(client, monkeypatch):
# Port fermé : connexion refusée dans le fil d'arrière-plan.
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
"http://127.0.0.1:59998/api/v1/evenements")
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
r = client.post("/board/api/pages", params={"title": "Malgré la panne"})
assert r.status_code == 200, r.text
page_id = r.json()["id"]
r = client.get(f"/board/api/pages/{page_id}")
assert r.status_code == 200, r.text
assert r.json()["title"] == "Malgré la panne"
r = client.post("/db/api", json={"name": "Collection malgré la panne"})
assert r.status_code == 200, r.text
+1 -3
View File
@@ -5,8 +5,6 @@ résolution des variables (aucun {{token}} survivant), dégradation sur
propriété renommée, défaut exclusif, idempotence, récurrence dédupliquée, propriété renommée, défaut exclusif, idempotence, récurrence dédupliquée,
API interne et annulation (undo). API interne et annulation (undo).
""" """
import json
import pytest import pytest
from app.db import get_conn from app.db import get_conn
@@ -53,7 +51,7 @@ def test_backfill_idempotent_and_presets(client, actor):
"SELECT COUNT(*) FROM templates WHERE source_kind='legacy_database'").fetchone()[0] "SELECT COUNT(*) FROM templates WHERE source_kind='legacy_database'").fetchone()[0]
assert reg_db == legacy_db and legacy_db > 0 assert reg_db == legacy_db and legacy_db > 0
# Galerie système : au moins 5 presets utilisables. # Galerie système : au moins 5 presets utilisables.
n_presets = TemplateService.ensure_system_presets(conn) TemplateService.ensure_system_presets(conn)
sys_rows = conn.execute( sys_rows = conn.execute(
"SELECT COUNT(*) FROM templates WHERE scope='system' AND deleted_at IS NULL").fetchone()[0] "SELECT COUNT(*) FROM templates WHERE scope='system' AND deleted_at IS NULL").fetchone()[0]
assert sys_rows >= 5 assert sys_rows >= 5
+34
View File
@@ -195,6 +195,40 @@ def test_mcp_client_bearer_sans_session_ni_csrf(client, jeton, donnees):
assert r.status_code == 401, r.text assert r.status_code == 401, r.text
def test_mcp_perimetre_du_jeton(client, jeton, donnees):
"""Régression 7.78.0 (cas réel de Bruno) : list_workspaces rendait
les 52 workspaces de la base, tous comptes confondus. Chaque jeton
ne voit que les workspaces de son propriétaire (ou ceux dont il
est membre) et les collections rattachées à ce périmètre."""
autre = "fd_token_autre_compte"
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (2, 'autre')")
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, revoked) "
"VALUES (2, 'autre', ?, ?, 0)",
(hashlib.sha256(autre.encode()).hexdigest(), autre[:12]))
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Espace de lautre', 2)")
wid_autre = cur.lastrowid
cur = conn.execute(
"INSERT INTO collections (name, description, icon, workspace_id) "
"VALUES ('Base de lautre', '', '📋', ?)", (wid_autre,))
base_autre = cur.lastrowid
conn.commit()
# Bruno (jeton principal) ne voit ni le workspace ni la base de l'autre.
noms = [w["name"] for w in _appel(client, jeton, 80, "list_workspaces")]
assert "Espace de lautre" not in noms
res = _appel(client, jeton, 81, "get_workspace", {"id": wid_autre})
assert "introuvable" in res["erreur"]
cols = [c["name"] for c in _appel(client, jeton, 82, "list_collections")]
assert "Base de lautre" not in cols and "Projet Alpha" in cols
res = _appel(client, jeton, 83, "get_collection", {"id": base_autre})
assert "introuvable" in res["erreur"]
# Et l'autre ne voit pas le workspace de Bruno, mais voit le sien.
noms2 = [w["name"] for w in _appel(client, autre, 84, "list_workspaces")]
assert noms2 == ["Espace de lautre"]
def test_mcp_info_get(client, jeton): def test_mcp_info_get(client, jeton):
r = client.get("/mcp") r = client.get("/mcp")
assert r.status_code == 200 assert r.status_code == 200
+5 -1
View File
@@ -396,7 +396,11 @@ def test_editor_front_end_wired(client):
assert "gtTok" in src and "fd-wiki-chip" in src # chip render + reader assert "gtTok" in src and "fd-wiki-chip" in src # chip render + reader
assert "wiki/pages" in src and "wiki/titles" in src # picker + label resolution assert "wiki/pages" in src and "wiki/titles" in src # picker + label resolution
assert "WM={" in src and "fdpage:" in src # [[ picker module assert "WM={" in src and "fdpage:" in src # [[ picker module
assert "_openTemplatePicker" in base and "page-templates" in base # Depuis la refonte templates (fc85481), le picker de base.html passe par
# le registre unifié (/api/templates) ; le câblage « page-templates » vit
# dans l'éditeur (fetch /board/api/page-templates) — chaque marqueur est
# donc vérifié dans le fichier qui le porte réellement.
assert "_openTemplatePicker" in base and "page-templates" in src
assert "toggleLock" in src and "saveAsTemplate" in src assert "toggleLock" in src and "saveAsTemplate" in src
css = pathlib.Path("static/css/app.css").read_text(encoding="utf-8") css = pathlib.Path("static/css/app.css").read_text(encoding="utf-8")
assert ".fd-wiki-chip" in css and ".fd-lock-banner" in css and ".tpl-picker-modal" in css assert ".fd-wiki-chip" in css and ".fd-lock-banner" in css and ".tpl-picker-modal" in css