Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd79a49d63 | ||
|
|
283e226adf | ||
|
|
53ba3d1124 | ||
|
|
8ce3b4b421 | ||
|
|
193fbd14ce | ||
|
|
3c8fc3070a | ||
|
|
49164ce057 | ||
|
|
f8449409b2 | ||
|
|
eb4c42b0b5 | ||
|
|
73b3fbce6e | ||
|
|
c7da7002ca | ||
|
|
851a7b17d2 | ||
|
|
e459b0f9b2 | ||
|
|
133156580c | ||
|
|
f6782ee5b3 | ||
|
|
215990adc0 | ||
|
|
fae335f808 | ||
|
|
465b72235c | ||
|
|
e36f80714c | ||
|
|
9fe5114669 |
@@ -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 :
|
||||||
|
|||||||
+351
@@ -1,5 +1,356 @@
|
|||||||
# Changelog - FlowDeck
|
# Changelog - FlowDeck
|
||||||
|
|
||||||
|
## v7.82.4 (2026-10-25) — Résolution dev/prod par l'hôte de la requête : le mode console du panneau revit en dev
|
||||||
|
|
||||||
|
- **Bug corrigé (dev)** : sur flowdeck-dev, le mode console du
|
||||||
|
panneau Spark restait une page morte — prouvé en direct avant
|
||||||
|
correctif : la 7.82.3 tournait bien (`GET /api/health` →
|
||||||
|
`7.82.3`), mais sa CSP servait `frame-src 'self'
|
||||||
|
https://agent.dracodev.net` (et `connect-src` idem), pointant
|
||||||
|
l'agent **prod, pas encore installée**, et la source de l'iframe
|
||||||
|
console (`DRACO_PANNEAU_CONFIG.agent_url`, rendue par
|
||||||
|
`base.html`) valait la même origine morte. Deux trous laissés par
|
||||||
|
la v7.82.3 : (1) `agent_url()` n'avait **aucune dérivation** —
|
||||||
|
même avec `AGENT_EVENEMENTS_URL` en `-dev`, l'agent lui-même
|
||||||
|
retombait sur le nom officiel ; (2) **aucune** résolution ne
|
||||||
|
lisait l'hôte de la requête — un `.env` dev sans
|
||||||
|
`DRACO_AGENT_URL` ni `AGENT_EVENEMENTS_URL` retombait
|
||||||
|
entièrement sur la prod.
|
||||||
|
- **Cure** (`app/services/draco_urls.py`, `app/middleware/security.py`) :
|
||||||
|
pour tout ce qui est **rendu à une requête** (liens de la barre /
|
||||||
|
`DRACO_URLS` servi, source de l'iframe du panneau, origine agent
|
||||||
|
des CSP `frame-src`/`connect-src`, boutons et redirections SSO —
|
||||||
|
émettrice via `DRACO_SSO_URL` résolu, callback =
|
||||||
|
`OAUTH_REDIRECT_URI` dont l'origine est sa propre origine
|
||||||
|
résolue), l'ordre de résolution devient : 1) variable explicite
|
||||||
|
de l'installation, prioritaire et inchangée ; 2) **hôte public de
|
||||||
|
la requête** (posé par le middleware CSP dans un `ContextVar`,
|
||||||
|
même patron que le nonce CSP ; `X-Forwarded-Host` sinon `Host`) —
|
||||||
|
motif fermé `^(og|flowdeck|tiktak|kanbante|agent)(<SUFFIXE>)\.dracodev\.net$`,
|
||||||
|
suffixe optionnel (`-dev`, vide en prod) appliqué à toutes les
|
||||||
|
sœurs **et à l'agent** ; jamais de suffixe inventé depuis un hôte
|
||||||
|
arbitraire, hors motif (LAN, IP) on passe à la suite ; 3)
|
||||||
|
dérivation par les adresses de l'agent connues en configuration,
|
||||||
|
**élargie** (URL d'événements **ou** base du service — notamment
|
||||||
|
`agent_url()` se dérive désormais de l'URL d'événements
|
||||||
|
explicite) — aussi la résolution des contextes **sans requête**,
|
||||||
|
inchangée sinon ; 4) défaut officiel. L'iframe du panneau et la
|
||||||
|
CSP de la même réponse consomment la **même** origine résolue :
|
||||||
|
les deux lisent `agent_url()` pendant la même requête.
|
||||||
|
- Tests : `tests/test_draco_urls_devprod.py` +12 (25/25 au total) —
|
||||||
|
requête `Host: flowdeck-dev.dracodev.net` et **aucune variable
|
||||||
|
d'environnement** → barre, iframe, CSP et SSO rendus entièrement
|
||||||
|
en `-dev` ; Host prod → noms officiels ; Host LAN → repli
|
||||||
|
config/défauts ; explicite gagne sur l'hôte ; motif fermé
|
||||||
|
(hôtes arbitraires → aucune dérivation) ; agent dérivé du seul
|
||||||
|
`AGENT_EVENEMENTS_URL` ; contextes sans requête inchangés.
|
||||||
|
Suite complète relue : **4 failed / 26 errors, identiques sur
|
||||||
|
arbre propre** (1307 passed en propre ; familles préexistantes
|
||||||
|
MCP/realtime/my_tasks/gallery/sync, sans rapport avec ce
|
||||||
|
correctif) — 1319 passed avec le correctif. Ruff vert.
|
||||||
|
|
||||||
|
## v7.82.3 (2026-10-25) — URLs sœurs dérivées de l'environnement de l'agent
|
||||||
|
|
||||||
|
- **Bug corrigé (dev)** : les URL sœurs servies par `draco_urls()`
|
||||||
|
(menu inter-apps, SSO, preset TikTak, références croisées)
|
||||||
|
restaient aux noms **prod** quand le `.env` ne déclarait pas les
|
||||||
|
quatre URL une à une — seul l'agent était en `-dev`, via
|
||||||
|
`AGENT_EVENEMENTS_URL`. Le menu inter-apps de la dev envoyait
|
||||||
|
donc vers la prod.
|
||||||
|
- **Dérivation d'environnement** (`app/services/draco_urls.py`,
|
||||||
|
résolution uniforme des quatre sœurs — ObsiGate, Flowdeck,
|
||||||
|
TikTak, Kanbante) : 1) URL explicite prioritaire, contrat
|
||||||
|
inchangé (`DRACO_SSO_URL`, `TIKTAK_URL`, `KANBANTE_URL`,
|
||||||
|
`FLOWDECK_URL`/`OAUTH_REDIRECT_URI`…) ; 2) sinon, si l'hôte de
|
||||||
|
l'agent tel que l'app le connaît déjà (`AGENT_EVENEMENTS_URL`,
|
||||||
|
via `agent_evenements_url()`) est de la forme
|
||||||
|
`agent<SUFFIXE>.dracodev.net` (SUFFIXE éventuellement vide,
|
||||||
|
typiquement `-dev`), l'URL sœur est dérivée :
|
||||||
|
`https://<slug><SUFFIXE>.dracodev.net` (slugs `og`, `flowdeck`,
|
||||||
|
`tiktak`, `kanbante`) ; hôte hors motif (autre domaine, LAN,
|
||||||
|
IP) = **aucune dérivation**, jamais un lien deviné ; 3) sinon
|
||||||
|
défaut officiel, comme avant. L'URL **Gitea ne change pas** ;
|
||||||
|
les gabarits et le JS de la barre, qui consomment `draco_urls()`,
|
||||||
|
sont inchangés.
|
||||||
|
- Tests : `tests/test_draco_urls_devprod.py` +4 — seul
|
||||||
|
`AGENT_EVENEMENTS_URL=…agent-dev…` posé → sœurs en `-dev` ;
|
||||||
|
explicite gagne sur la dérivation ; hôtes d'agent hors motif
|
||||||
|
→ défauts officiels ; agent officiel (suffixe vide) → sœurs
|
||||||
|
officielles. Non-régression « aucun env » déjà couverte.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **La suppression définitive depuis la corbeille échouait (500)**
|
||||||
|
pour toute page réelle : `favorites.page_id` et `recents.page_id`
|
||||||
|
référencent `pages(id)` sans CASCADE, avec les clés étrangères
|
||||||
|
actives — un `IntegrityError` dès que la page avait été ouverte
|
||||||
|
ou mise en favori. La purge automatique des 30 jours mourait sur
|
||||||
|
la même erreur, en silence. Nouveau nettoyage partagé
|
||||||
|
`detach_page_references`, appliqué aux trois chemins (suppression
|
||||||
|
unitaire, Empty Trash, purge planifiée). Cas réel de Bruno :
|
||||||
|
4 pages impossibles à supprimer. Test de régression ajouté.
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **MCP : `list_workspaces` et `get_workspace`** — les workspaces
|
||||||
|
(conteneurs de pages : Techno, Famille Charest…) sont enfin
|
||||||
|
exposés au service d'agents, et les descriptions des outils
|
||||||
|
collections disent désormais clairement qu'une collection est une
|
||||||
|
base de données, pas un workspace (la réponse « 17 collections
|
||||||
|
(espaces de travail) » du panneau a induit Bruno en erreur).
|
||||||
|
|
||||||
|
## v7.76.1 (2026-10-22) — Correctif : le MCP était bloqué par le CSRF
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **Tout client MCP réel recevait 403 sur `initialize`** : le
|
||||||
|
middleware CSRF exigeait un jeton de session pour tout POST hors
|
||||||
|
préfixes exemptés, et `/mcp` n'y était pas — alors que c'est du
|
||||||
|
machine-à-machine authentifié par jeton d'API (Bearer), comme
|
||||||
|
`/scim/v2`. Les tests ne voyaient rien : leur client porte une
|
||||||
|
session et un CSRF. `/mcp` rejoint les préfixes exemptés du CSRF
|
||||||
|
(son authentification propre est inchangée : sans jeton valide,
|
||||||
|
toujours 401). Test de régression ajouté : client Bearer anonyme,
|
||||||
|
sans session ni CSRF.
|
||||||
|
|
||||||
|
## v7.76.0 (2026-10-21) — Fusion : serveur MCP en écriture
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Serveur MCP en écriture** (`POST /mcp`) : en plus des lectures
|
||||||
|
existantes, trois nouveaux outils — `create_page`
|
||||||
|
(`collection_id`, `title`, `icon` optionnel, `properties` optionnel
|
||||||
|
→ `property_values_json`), `update_page` (`id`, `title` optionnel,
|
||||||
|
`properties` optionnel qui remplace `property_values_json`) et
|
||||||
|
`create_collection` (`name`, `description` optionnel, `icon`
|
||||||
|
optionnel, avec vue par défaut comme l'API collections native).
|
||||||
|
Aucune suppression. Les erreurs métier (collection/page
|
||||||
|
introuvable, titre vide) reviennent en `{"erreur": "..."}` dans le
|
||||||
|
contenu texte, comme les lectures. `GET /mcp` reflète les nouveaux
|
||||||
|
outils (`read_only: false`). Authentification inchangée : jetons
|
||||||
|
d'API personnels existants.
|
||||||
|
- **Redéploiement Docker à la charge de Bruno** pour mettre cette
|
||||||
|
version en production.
|
||||||
|
|
||||||
|
## v7.75.0 (2026-10-21) — Fusion : serveur MCP + panneau Spark
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Serveur MCP en lecture seule** (`POST /mcp`, JSON-RPC, même
|
||||||
|
dialecte que TikTak et Kanbante) : `list_collections`,
|
||||||
|
`get_collection`, `get_page`, `search_pages`, `list_repos`.
|
||||||
|
Authentification par les **jetons d'API personnels existants**
|
||||||
|
(Paramètres → Jetons d'API) — le service d'agents lit au nom du
|
||||||
|
propriétaire du jeton qu'on lui crée. Flowdeck était la seule des
|
||||||
|
quatre applications sans porte MCP ; c'est corrigé.
|
||||||
|
- **Panneau Spark intégré** : le bouton Spark de la barre commune
|
||||||
|
ouvre le panneau de l'agent commun (conversation, Aujourd'hui,
|
||||||
|
approbations), assets vendangés à l'identique de TikTak.
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
|
||||||
|
- **CSP `connect-src`** : exception nommée et unique pour
|
||||||
|
`https://agent.dracodev.net` (le service d'agents, premier parti) —
|
||||||
|
le panneau en a besoin pour parler au service. Aucun autre hôte
|
||||||
|
externe n'est ouvert ; le `https:` générique reste interdit
|
||||||
|
(test d'audit mis à jour en conséquence).
|
||||||
|
|
||||||
## v7.74.3 (2026-10-21) — Barre commune : le menu du ✦ arrive enfin + palier 900 px
|
## v7.74.3 (2026-10-21) — Barre commune : le menu du ✦ arrive enfin + palier 900 px
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
-1
@@ -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.4"
|
||||||
|
# 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,6 +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)
|
||||||
|
app.include_router(mcp_server_router) # Fusion — serveur MCP (service d'agents)
|
||||||
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)
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||||
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
|
# NOTE: ``/scim/v2`` is Bearer-token only (no cookie auth), so CSRF does not
|
||||||
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
|
# apply — IdP SCIM clients (Okta, Entra) cannot send an X-CSRF-Token.
|
||||||
|
# Même logique pour ``/mcp`` (Fusion, 7.76.1) : le serveur MCP est
|
||||||
|
# machine-à-machine, authentifié par les jetons d'API personnels
|
||||||
|
# (Bearer) — le service d'agents ne peut pas envoyer de X-CSRF-Token.
|
||||||
|
# Sans cette exemption, tout POST /mcp mourait en 403 avant la route
|
||||||
|
# (les tests passaient : leur client porte session + CSRF).
|
||||||
# A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté.
|
# A19 — LISTE FINALE : plus aucun préfixe cookie-auth n'est exempté.
|
||||||
# Tous les appels non-GET du front envoient désormais `X-CSRF-Token`
|
# Tous les appels non-GET du front envoient désormais `X-CSRF-Token`
|
||||||
# (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace,
|
# (46 sites équipés en v7.3.6 : agent_panel, settings, local_workspace,
|
||||||
@@ -30,7 +35,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|||||||
# - publics : /s/ (sites), /f/ (forms)
|
# - publics : /s/ (sites), /f/ (forms)
|
||||||
# - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error
|
# - infra/diag : /api/csrf-token (le jeton lui-même), /api/frontend-error
|
||||||
EXCLUDED_PATHS = {
|
EXCLUDED_PATHS = {
|
||||||
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2",
|
"/api/webhook", "/api/v1", "/api/v2", "/scim/v2", "/mcp",
|
||||||
"/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify",
|
"/auth/callback", "/auth/register", "/auth/local-login", "/auth/local-verify",
|
||||||
"/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn",
|
"/auth/2fa", "/auth/saml", "/auth/oidc", "/auth/webauthn",
|
||||||
"/s/", "/f/", "/api/csrf-token", "/api/frontend-error",
|
"/s/", "/f/", "/api/csrf-token", "/api/frontend-error",
|
||||||
|
|||||||
@@ -130,9 +130,18 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
|||||||
# connect-src fermé : plus de `https:` (aucun fetch cross-origin
|
# connect-src fermé : plus de `https:` (aucun fetch cross-origin
|
||||||
# côté front — grep négatif) et websockets scopés à l'hôte de la
|
# côté front — grep négatif) et websockets scopés à l'hôte de la
|
||||||
# requête ({host}) → plus de canal d'exfil vers un tiers.
|
# requête ({host}) → plus de canal d'exfil vers un tiers.
|
||||||
"connect-src 'self' ws://{host} wss://{host}; "
|
# Exception Fusion (2026-10-21) : le panneau Spark parle au
|
||||||
|
# service d'agents commun (premier parti, même programme) —
|
||||||
|
# seul hôte externe nommé, rien d'autre ne s'ouvre.
|
||||||
|
# v7.82.0 (dev/prod) : cet hôte n'est plus figé — {agent} est
|
||||||
|
# l'origine résolue par draco_urls (DRACO_AGENT_URL explicite,
|
||||||
|
# sinon hôte de la requête — v7.82.4 —, sinon dérivation par
|
||||||
|
# l'agent connu en config ; défaut = nom officiel).
|
||||||
|
# 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'; "
|
||||||
@@ -144,6 +153,17 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
|||||||
# tâche créée maintenant → le contexte (donc le nonce) y est copié,
|
# tâche créée maintenant → le contexte (donc le nonce) y est copié,
|
||||||
# exactement ce que les templates liront via `csp_nonce()`.
|
# exactement ce que les templates liront via `csp_nonce()`.
|
||||||
CSP_NONCE.set(nonce)
|
CSP_NONCE.set(nonce)
|
||||||
|
# v7.82.4 : même patron pour l'hôte public de la requête —
|
||||||
|
# `draco_urls` le lit pour résoudre l'environnement (dev/prod)
|
||||||
|
# de tout ce qui est rendu à cette requête (barre, iframe du
|
||||||
|
# panneau, SSO), et la CSP ci-dessous consomme la MÊME origine
|
||||||
|
# agent résolue. X-Forwarded-Host prime, comme
|
||||||
|
# `get_redirect_uri()` (app/routers/auth.py).
|
||||||
|
from app.services import draco_urls
|
||||||
|
|
||||||
|
draco_urls.definir_hote_requete(
|
||||||
|
request.headers.get("x-forwarded-host", "")
|
||||||
|
or request.headers.get("host", ""))
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
# Only set CSP on HTML responses
|
# Only set CSP on HTML responses
|
||||||
content_type = response.headers.get("content-type", "")
|
content_type = response.headers.get("content-type", "")
|
||||||
@@ -152,8 +172,10 @@ 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", ""))
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+24
-2
@@ -32,12 +32,34 @@ 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) ».
|
||||||
|
|
||||||
|
Émettrice = ``obsigate_url()`` (DRACO_SSO_URL explicite, sinon
|
||||||
|
l'environnement de la requête — v7.82.4) ; retour = URL publique
|
||||||
|
de cette instance résolue de la même façon (FLOWDECK_URL /
|
||||||
|
OAUTH_REDIRECT_URI explicites, sinon hôte de la requête, le
|
||||||
|
callback = sa propre origine résolue) + ``/auth/draco``. Aucun
|
||||||
|
nom public figé.
|
||||||
|
"""
|
||||||
|
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 +125,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">
|
||||||
|
|||||||
@@ -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"]}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.services.collection_lifecycle import (
|
|||||||
collections_hosted_by_page,
|
collections_hosted_by_page,
|
||||||
collections_hosted_by_pages,
|
collections_hosted_by_pages,
|
||||||
delete_collections,
|
delete_collections,
|
||||||
|
detach_page_references,
|
||||||
)
|
)
|
||||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||||
|
|
||||||
@@ -199,6 +200,7 @@ def permanent_delete(request: Request, page_id: int):
|
|||||||
# tâches sans document.
|
# tâches sans document.
|
||||||
collections = collections_hosted_by_page(conn, page_id)
|
collections = collections_hosted_by_page(conn, page_id)
|
||||||
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
||||||
|
detach_page_references(conn, [page_id])
|
||||||
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||||
delete_collections(conn, collections)
|
delete_collections(conn, collections)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -229,6 +231,7 @@ def empty_trash(request: Request):
|
|||||||
"UPDATE pages SET parent_id=NULL WHERE parent_id IN "
|
"UPDATE pages SET parent_id=NULL WHERE parent_id IN "
|
||||||
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
|
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
|
||||||
)
|
)
|
||||||
|
detach_page_references(conn, trashed)
|
||||||
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
|
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
|
||||||
delete_collections(conn, collections)
|
delete_collections(conn, collections)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"%'
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
"""FlowDeck — serveur MCP (Fusion, branchement du service d'agents).
|
||||||
|
|
||||||
|
Même dialecte JSON-RPC que les autres apps du programme (TikTak,
|
||||||
|
Kanbante) : POST /mcp, initialize → tools/list → tools/call,
|
||||||
|
réponses en contenu texte JSON.
|
||||||
|
|
||||||
|
Authentification : les jetons d'API personnels existants
|
||||||
|
(Settings → API tokens, table ``api_tokens``, vérifiés par
|
||||||
|
``public_api._token_owner``) — aucune nouvelle machinerie de clés.
|
||||||
|
Le service d'agents reçoit un jeton dédié créé par l'utilisateur ;
|
||||||
|
il lit et écrit en son nom.
|
||||||
|
|
||||||
|
Objets exposés : les **workspaces** (conteneurs de pages) et leurs
|
||||||
|
pages, les **collections** (bases de données type Notion — ne pas
|
||||||
|
confondre : une collection vit dans un workspace ou une page), la
|
||||||
|
recherche par titre, et le registre des dépôts (table ``projects``).
|
||||||
|
Doctrine du programme : Flowdeck
|
||||||
|
référence les tâches, il ne les copie pas — aucune tâche TikTak ne
|
||||||
|
transite par ici. Écritures limitées à la création/mise à jour de
|
||||||
|
pages et à la création de collections ; aucune suppression.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.routers.public_api import _token_owner
|
||||||
|
from app.services.collection_lifecycle import (
|
||||||
|
effective_workspace_sql,
|
||||||
|
user_workspace_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["mcp"])
|
||||||
|
|
||||||
|
OUTILS = [
|
||||||
|
{"name": "list_workspaces",
|
||||||
|
"description": "Liste les WORKSPACES Flowdeck du propriétaire du jeton "
|
||||||
|
"(propriétaire ou membre) — ses conteneurs de pages "
|
||||||
|
"(ex. Techno, Famille Charest), avec leur nombre de pages "
|
||||||
|
"actives. À ne pas confondre avec les collections "
|
||||||
|
"(bases de données).",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}}},
|
||||||
|
{"name": "get_workspace",
|
||||||
|
"description": "Lit un workspace et l'arborescence de ses pages "
|
||||||
|
"(titres, parent, section) — pages classiques, pas les "
|
||||||
|
"lignes des collections.",
|
||||||
|
"inputSchema": {"type": "object",
|
||||||
|
"properties": {"id": {"type": "integer"}},
|
||||||
|
"required": ["id"]}},
|
||||||
|
{"name": "list_collections",
|
||||||
|
"description": "Liste les COLLECTIONS Flowdeck — les bases de données "
|
||||||
|
"de type Notion (tables, CRM, calendriers, formulaires), "
|
||||||
|
"avec leur nombre de lignes. Ce ne sont PAS les workspaces : "
|
||||||
|
"une collection vit dans un workspace ou dans une page.",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}}},
|
||||||
|
{"name": "get_collection",
|
||||||
|
"description": "Lit une collection et ses pages (titres et propriétés).",
|
||||||
|
"inputSchema": {"type": "object",
|
||||||
|
"properties": {"id": {"type": "integer"}},
|
||||||
|
"required": ["id"]}},
|
||||||
|
{"name": "get_page",
|
||||||
|
"description": "Lit une page de collection par son identifiant (titre, propriétés, contenu).",
|
||||||
|
"inputSchema": {"type": "object",
|
||||||
|
"properties": {"id": {"type": "integer"}},
|
||||||
|
"required": ["id"]}},
|
||||||
|
{"name": "search_pages",
|
||||||
|
"description": "Recherche des pages par mots dans leur titre, toutes collections confondues.",
|
||||||
|
"inputSchema": {"type": "object",
|
||||||
|
"properties": {"query": {"type": "string"}},
|
||||||
|
"required": ["query"]}},
|
||||||
|
{"name": "list_repos",
|
||||||
|
"description": "Liste le registre des dépôts suivis par Flowdeck (builtin, Gitea, GitHub).",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}}},
|
||||||
|
{"name": "create_page",
|
||||||
|
"description": "Crée une page dans une collection Flowdeck.",
|
||||||
|
"inputSchema": {"type": "object",
|
||||||
|
"properties": {"collection_id": {"type": "integer"},
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"icon": {"type": "string"},
|
||||||
|
"properties": {"type": "object"}},
|
||||||
|
"required": ["collection_id", "title"]}},
|
||||||
|
{"name": "update_page",
|
||||||
|
"description": "Met à jour une page Flowdeck (titre et/ou propriétés ; "
|
||||||
|
"les propriétés fournies remplacent property_values_json).",
|
||||||
|
"inputSchema": {"type": "object",
|
||||||
|
"properties": {"id": {"type": "integer"},
|
||||||
|
"title": {"type": "string"},
|
||||||
|
"properties": {"type": "object"}},
|
||||||
|
"required": ["id"]}},
|
||||||
|
{"name": "create_collection",
|
||||||
|
"description": "Crée une collection Flowdeck (projet / espace de travail).",
|
||||||
|
"inputSchema": {"type": "object",
|
||||||
|
"properties": {"name": {"type": "string"},
|
||||||
|
"description": {"type": "string"},
|
||||||
|
"icon": {"type": "string"}},
|
||||||
|
"required": ["name"]}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _props(brut):
|
||||||
|
"""property_values_json → dict quand c'est du JSON, sinon inchangé."""
|
||||||
|
if not brut:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(brut)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return brut
|
||||||
|
|
||||||
|
|
||||||
|
def _page_resume(row) -> dict:
|
||||||
|
return {"id": row["id"], "collection_id": row["collection_id"],
|
||||||
|
"title": row["title"], "icon": row["icon"],
|
||||||
|
"properties": _props(row["property_values_json"]),
|
||||||
|
"updated_at": row["updated_at"]}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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":
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT w.id, w.name, w.owner_id, "
|
||||||
|
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id = w.id "
|
||||||
|
" AND p.deleted_at IS NULL) AS page_count "
|
||||||
|
"FROM workspaces w ORDER BY w.name").fetchall()
|
||||||
|
return [dict(r) for r in rows if r["id"] in accessible]
|
||||||
|
if nom == "get_workspace":
|
||||||
|
ws = conn.execute("SELECT * FROM workspaces WHERE id = ?",
|
||||||
|
(int(args.get("id", 0)),)).fetchone()
|
||||||
|
if ws is None or ws["id"] not in accessible:
|
||||||
|
return {"erreur": "workspace introuvable"}
|
||||||
|
pages = conn.execute(
|
||||||
|
"SELECT id, title, parent_id, parent_section, updated_at "
|
||||||
|
"FROM pages WHERE workspace_id = ? AND deleted_at IS NULL "
|
||||||
|
"ORDER BY parent_id, title LIMIT 300", (ws["id"],)).fetchall()
|
||||||
|
return {"workspace": {"id": ws["id"], "name": ws["name"],
|
||||||
|
"owner_id": ws["owner_id"]},
|
||||||
|
"pages": [dict(p) for p in pages]}
|
||||||
|
if nom == "list_collections":
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p "
|
||||||
|
f"WHERE p.collection_id = c.id) AS page_count, "
|
||||||
|
f"{eff_sql} AS eff_ws "
|
||||||
|
"FROM collections c ORDER BY c.name").fetchall()
|
||||||
|
return [{"id": r["id"], "name": r["name"],
|
||||||
|
"description": r["description"], "icon": r["icon"],
|
||||||
|
"is_task": bool(r["is_task"]) if "is_task" in r.keys() else False,
|
||||||
|
"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":
|
||||||
|
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
|
||||||
|
(int(args.get("id", 0)),)).fetchone()
|
||||||
|
if coll is None or not collection_visible(coll["id"]):
|
||||||
|
return {"erreur": "collection introuvable"}
|
||||||
|
pages = conn.execute(
|
||||||
|
"SELECT * FROM collection_pages WHERE collection_id = ? "
|
||||||
|
"ORDER BY position, id", (coll["id"],)).fetchall()
|
||||||
|
return {"collection": {"id": coll["id"], "name": coll["name"],
|
||||||
|
"description": coll["description"],
|
||||||
|
"icon": coll["icon"]},
|
||||||
|
"pages": [_page_resume(p) for p in pages]}
|
||||||
|
if nom == "get_page":
|
||||||
|
p = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
|
||||||
|
(int(args.get("id", 0)),)).fetchone()
|
||||||
|
if p is None or not collection_visible(p["collection_id"]):
|
||||||
|
return {"erreur": "page introuvable"}
|
||||||
|
res = _page_resume(p)
|
||||||
|
for cle in ("content", "blocks", "blocks_json"):
|
||||||
|
if cle in p.keys() and p[cle]:
|
||||||
|
res[cle] = p[cle]
|
||||||
|
return res
|
||||||
|
if nom == "search_pages":
|
||||||
|
q = f"%{str(args.get('query', '')).strip()}%"
|
||||||
|
if q == "%%":
|
||||||
|
return []
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM collection_pages WHERE title LIKE ? "
|
||||||
|
"ORDER BY updated_at DESC LIMIT 25", (q,)).fetchall()
|
||||||
|
return [_page_resume(p) for p in rows
|
||||||
|
if collection_visible(p["collection_id"])]
|
||||||
|
if nom == "list_repos":
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, name, proj_type, owner, language, description "
|
||||||
|
"FROM projects ORDER BY proj_type, name").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
if nom == "create_page":
|
||||||
|
try:
|
||||||
|
collection_id = int(args.get("collection_id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {"erreur": "collection_id requis (entier)"}
|
||||||
|
title = str(args.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
return {"erreur": "titre vide"}
|
||||||
|
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
|
||||||
|
(collection_id,)).fetchone()
|
||||||
|
if coll is None or not collection_visible(collection_id):
|
||||||
|
return {"erreur": "collection introuvable"}
|
||||||
|
props = args.get("properties")
|
||||||
|
if props is None:
|
||||||
|
props = {}
|
||||||
|
if not isinstance(props, dict):
|
||||||
|
return {"erreur": "properties doit être un objet"}
|
||||||
|
icon = str(args.get("icon") or "file")
|
||||||
|
max_pos = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages "
|
||||||
|
"WHERE collection_id = ?", (collection_id,)).fetchone()[0]
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO collection_pages "
|
||||||
|
"(collection_id, title, icon, position, property_values_json) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(collection_id, title, icon, max_pos, json.dumps(props, ensure_ascii=False)))
|
||||||
|
conn.commit()
|
||||||
|
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
|
||||||
|
(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)
|
||||||
|
if nom == "update_page":
|
||||||
|
try:
|
||||||
|
page_id = int(args.get("id"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return {"erreur": "id requis (entier)"}
|
||||||
|
page = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
|
||||||
|
(page_id,)).fetchone()
|
||||||
|
if page is None or not collection_visible(page["collection_id"]):
|
||||||
|
return {"erreur": "page introuvable"}
|
||||||
|
title = page["title"]
|
||||||
|
if "title" in args and args.get("title") is not None:
|
||||||
|
title = str(args.get("title") or "").strip()
|
||||||
|
if not title:
|
||||||
|
return {"erreur": "titre vide"}
|
||||||
|
props_json = page["property_values_json"]
|
||||||
|
if "properties" in args and args.get("properties") is not None:
|
||||||
|
props = args.get("properties")
|
||||||
|
if not isinstance(props, dict):
|
||||||
|
return {"erreur": "properties doit être un objet"}
|
||||||
|
props_json = json.dumps(props, ensure_ascii=False)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE collection_pages SET title = ?, property_values_json = ?, "
|
||||||
|
"updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
(title, props_json, page_id))
|
||||||
|
conn.commit()
|
||||||
|
updated = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
|
||||||
|
(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)
|
||||||
|
if nom == "create_collection":
|
||||||
|
name = str(args.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return {"erreur": "nom vide"}
|
||||||
|
description = str(args.get("description") or "")
|
||||||
|
icon = str(args.get("icon") or "📋")
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO collections (name, description, icon, schema_json) "
|
||||||
|
"VALUES (?, ?, ?, '[]')",
|
||||||
|
(name, description, icon))
|
||||||
|
new_id = cur.lastrowid
|
||||||
|
# Vue par défaut, comme la création via l'API collections
|
||||||
|
# (app/routers/collections/crud.py).
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO collection_views "
|
||||||
|
"(collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)",
|
||||||
|
(new_id, "Default View", "table", json.dumps({
|
||||||
|
"visible_properties": ["Title"], "sorts": [], "filters": []})))
|
||||||
|
conn.commit()
|
||||||
|
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
|
||||||
|
(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"],
|
||||||
|
"description": coll["description"], "icon": coll["icon"]}
|
||||||
|
return {"erreur": f"outil inconnu : {nom}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _version() -> str:
|
||||||
|
import pathlib
|
||||||
|
try:
|
||||||
|
return (pathlib.Path(__file__).resolve().parent.parent.parent
|
||||||
|
/ "VERSION").read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
return "dev"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mcp")
|
||||||
|
def mcp_info():
|
||||||
|
return {"name": "flowdeck", "version": _version(),
|
||||||
|
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
|
||||||
|
"read_only": False, "tools": [t["name"] for t in OUTILS]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mcp")
|
||||||
|
async def mcp(request: Request):
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
||||||
|
proprietaire = _token_owner(token) if token else None
|
||||||
|
if proprietaire is None:
|
||||||
|
return JSONResponse(
|
||||||
|
{"jsonrpc": "2.0", "id": None,
|
||||||
|
"error": {"code": -32001, "message": "Authentification requise (jeton d'API Flowdeck)"}},
|
||||||
|
status_code=401)
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"jsonrpc": "2.0", "id": None,
|
||||||
|
"error": {"code": -32700, "message": "JSON invalide"}},
|
||||||
|
status_code=400)
|
||||||
|
methode = data.get("method", "")
|
||||||
|
rid = data.get("id")
|
||||||
|
if rid is None: # notification : rien à répondre
|
||||||
|
return Response(status_code=202)
|
||||||
|
if methode == "initialize":
|
||||||
|
return {"jsonrpc": "2.0", "id": rid,
|
||||||
|
"result": {"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {"tools": {}},
|
||||||
|
"serverInfo": {"name": "flowdeck", "version": _version()}}}
|
||||||
|
if methode == "ping":
|
||||||
|
return {"jsonrpc": "2.0", "id": rid, "result": {}}
|
||||||
|
if methode == "tools/list":
|
||||||
|
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": OUTILS}}
|
||||||
|
if methode == "tools/call":
|
||||||
|
params = data.get("params") or {}
|
||||||
|
res = _appel_outil(params.get("name", ""), params.get("arguments") or {},
|
||||||
|
proprietaire["user_id"])
|
||||||
|
return {"jsonrpc": "2.0", "id": rid,
|
||||||
|
"result": {"content": [{"type": "text",
|
||||||
|
"text": json.dumps(res, ensure_ascii=False)}]}}
|
||||||
|
return {"jsonrpc": "2.0", "id": rid,
|
||||||
|
"error": {"code": -32601, "message": f"Méthode inconnue : {methode}"}}
|
||||||
@@ -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
|
||||||
@@ -51,6 +51,26 @@ EFFECTIVE_WORKSPACE_SQL = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def detach_page_references(conn, page_ids) -> None:
|
||||||
|
"""Supprime les lignes qui référencent des pages en ``NO ACTION``.
|
||||||
|
|
||||||
|
``favorites.page_id`` et ``recents.page_id`` référencent
|
||||||
|
``pages(id)`` sans CASCADE et la connexion applique
|
||||||
|
``PRAGMA foreign_keys=ON`` : supprimer définitivement une page
|
||||||
|
réelle — ouverte un jour (recents) ou mise en favori — levait un
|
||||||
|
``IntegrityError`` (500 côté API, échec silencieux de la purge
|
||||||
|
automatique des 30 jours). À appeler avant tout
|
||||||
|
``DELETE FROM pages`` définitif. (Régression du 2026-10-22 :
|
||||||
|
les pages synthétiques des tests n'avaient ni favori ni récent.)
|
||||||
|
"""
|
||||||
|
ids = [int(i) for i in page_ids]
|
||||||
|
if not ids:
|
||||||
|
return
|
||||||
|
ph = ",".join("?" * len(ids))
|
||||||
|
conn.execute(f"DELETE FROM favorites WHERE page_id IN ({ph})", ids)
|
||||||
|
conn.execute(f"DELETE FROM recents WHERE page_id IN ({ph})", ids)
|
||||||
|
|
||||||
|
|
||||||
def effective_workspace_sql(alias: str = "c") -> str:
|
def effective_workspace_sql(alias: str = "c") -> str:
|
||||||
"""Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline)."""
|
"""Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline)."""
|
||||||
return EFFECTIVE_WORKSPACE_SQL.format(a=alias)
|
return EFFECTIVE_WORKSPACE_SQL.format(a=alias)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"""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``.
|
||||||
|
|
||||||
|
**Dérivation d'environnement (v7.82.3)** — pour les quatre URL
|
||||||
|
sœurs (ObsiGate, Flowdeck, TikTak, Kanbante), entre la configuration
|
||||||
|
explicite et le défaut officiel : si l'origine de l'agent telle que
|
||||||
|
l'app la connaît déjà (``AGENT_EVENEMENTS_URL``, via
|
||||||
|
``agent_evenements_url()``) a un hôte de la forme
|
||||||
|
``agent<SUFFIXE>.dracodev.net`` (SUFFIXE éventuellement vide,
|
||||||
|
typiquement ``-dev``), l'URL sœur est dérivée :
|
||||||
|
``https://<slug><SUFFIXE>.dracodev.net`` (slugs : ``og``,
|
||||||
|
``flowdeck``, ``tiktak``, ``kanbante``). Ainsi une installation dev
|
||||||
|
qui ne déclare que l'agent pointe ses liens inter-apps en ``-dev``
|
||||||
|
sans quatre variables de plus. Si l'hôte de l'agent ne correspond
|
||||||
|
pas au motif (autre domaine, LAN, IP), **aucune dérivation** : on
|
||||||
|
ne fabrique jamais un lien deviné. L'URL Gitea ne change pas.
|
||||||
|
|
||||||
|
**Résolution par l'hôte de la requête (v7.82.4)** — la v7.82.3
|
||||||
|
laissait deux trous, prouvés en direct sur flowdeck-dev (CSP
|
||||||
|
``frame-src 'self' https://agent.dracodev.net`` alors que la prod
|
||||||
|
n'existe pas encore → console du panneau = page morte) :
|
||||||
|
|
||||||
|
1. ``agent_url()`` n'avait **aucune** dérivation : même avec
|
||||||
|
``AGENT_EVENEMENTS_URL`` en ``-dev``, l'agent lui-même retombait
|
||||||
|
sur le nom officiel ;
|
||||||
|
2. **aucune** résolution ne lisait l'hôte de la requête : un
|
||||||
|
``.env`` dev sans ``DRACO_AGENT_URL`` ni ``AGENT_EVENEMENTS_URL``
|
||||||
|
retombait entièrement sur la prod.
|
||||||
|
|
||||||
|
Pour tout ce qui est **rendu à une requête** (liens de la barre /
|
||||||
|
``DRACO_URLS`` servi, source de l'iframe du panneau, origine agent
|
||||||
|
des CSP ``frame-src``/``connect-src``, boutons et redirections
|
||||||
|
SSO), l'ordre de résolution devient :
|
||||||
|
|
||||||
|
1. variable explicite de l'installation — prioritaire, inchangée ;
|
||||||
|
2. **hôte public de la requête** (posé par le middleware CSP dans
|
||||||
|
``HOTE_REQUETE``, même patron que le nonce CSP) : motif fermé
|
||||||
|
``^(og|flowdeck|tiktak|kanbante|agent)(<SUFFIXE>)\\.dracodev\\.net$``
|
||||||
|
(suffixe optionnel, p. ex. ``-dev``, vide en prod) →
|
||||||
|
l'environnement = ce suffixe, appliqué à toutes les sœurs **et
|
||||||
|
à l'agent**. Jamais de suffixe inventé depuis un hôte arbitraire ;
|
||||||
|
hors motif (LAN, IP) → étape suivante ;
|
||||||
|
3. dérivation par les adresses de l'agent connues en configuration
|
||||||
|
(URL d'événements **ou** base du service) — aussi la résolution
|
||||||
|
des contextes **sans requête**, inchangée sinon ;
|
||||||
|
4. défaut officiel.
|
||||||
|
|
||||||
|
L'iframe du panneau et la CSP de la même réponse consomment la
|
||||||
|
même origine résolue : les deux lisent ``agent_url()`` pendant la
|
||||||
|
même requête, avec le même ``HOTE_REQUETE``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
# Hôte public de la requête en cours (``X-Forwarded-Host`` sinon
|
||||||
|
# ``Host``), posé par le middleware CSP avant le traitement — même
|
||||||
|
# patron que ``CSP_NONCE`` : le contexte est copié dans la tâche qui
|
||||||
|
# rend les templates. Vide hors requête (tâches de fond, CLI, tests
|
||||||
|
# unitaires) → la résolution retombe sur la configuration.
|
||||||
|
HOTE_REQUETE: ContextVar[str] = ContextVar("draco_hote_requete", default="")
|
||||||
|
|
||||||
|
|
||||||
|
def definir_hote_requete(hote: str) -> None:
|
||||||
|
"""Mémorise l'hôte public de la requête (premier de la liste)."""
|
||||||
|
HOTE_REQUETE.set((hote or "").split(",")[0].strip().lower())
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
|
||||||
|
# Slugs d'hôte des applications sœurs (dérivation d'environnement).
|
||||||
|
_SLUGS_SOEURS = {
|
||||||
|
"obsigate": "og",
|
||||||
|
"flowdeck": "flowdeck",
|
||||||
|
"tiktak": "tiktak",
|
||||||
|
"kanbante": "kanbante",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hôte de l'agent : agent<SUFFIXE>.dracodev.net (SUFFIXE = ``-dev``…).
|
||||||
|
_MOTIF_HOTE_AGENT = re.compile(r"^agent(-[a-z0-9-]+)?\.dracodev\.net$")
|
||||||
|
|
||||||
|
# Hôte public d'une requête : motif FERMÉ des cinq applications.
|
||||||
|
# Le suffixe capturé (groupe 2, p. ex. ``-dev``) est l'environnement
|
||||||
|
# de TOUTE l'installation — jamais un suffixe inventé depuis un
|
||||||
|
# hôte arbitraire : hors motif (autre domaine, LAN, IP), None.
|
||||||
|
_MOTIF_HOTE_PUBLIC = re.compile(
|
||||||
|
r"^(og|flowdeck|tiktak|kanbante|agent)(-[a-z0-9-]+)?\.dracodev\.net$")
|
||||||
|
|
||||||
|
# Slugs pour la résolution par l'hôte de la requête (l'agent inclus).
|
||||||
|
_SLUGS_TOUTES = {**_SLUGS_SOEURS, "agent": "agent"}
|
||||||
|
|
||||||
|
|
||||||
|
def _suffixe_environnement() -> str | None:
|
||||||
|
"""Suffixe d'environnement porté par l'hôte de l'agent, ou None.
|
||||||
|
|
||||||
|
Source : l'origine de l'agent telle que l'app la connaît déjà —
|
||||||
|
``agent_evenements_url()`` (``AGENT_EVENEMENTS_URL`` explicite,
|
||||||
|
sinon dérivée de la base de l'agent). Retourne ``""`` pour
|
||||||
|
l'hôte officiel, ``"-dev"`` pour ``agent-dev.dracodev.net``, et
|
||||||
|
None si l'hôte ne correspond pas au motif (aucune dérivation).
|
||||||
|
"""
|
||||||
|
hote_agent = (urlparse(agent_evenements_url()).hostname or "").lower()
|
||||||
|
m = _MOTIF_HOTE_AGENT.match(hote_agent)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return m.group(1) or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _url_derivee(app: str) -> str:
|
||||||
|
"""URL sœur dérivée de l'environnement de l'agent ("" si aucune)."""
|
||||||
|
slug = _SLUGS_SOEURS.get(app)
|
||||||
|
if not slug:
|
||||||
|
return ""
|
||||||
|
suffixe = _suffixe_environnement()
|
||||||
|
if suffixe is None:
|
||||||
|
return ""
|
||||||
|
return f"https://{slug}{suffixe}.dracodev.net"
|
||||||
|
|
||||||
|
|
||||||
|
def _suffixe_requete() -> str | None:
|
||||||
|
"""Suffixe d'environnement porté par l'hôte de la requête, ou None.
|
||||||
|
|
||||||
|
Motif fermé ``_MOTIF_HOTE_PUBLIC`` sur l'hôte public mémorisé
|
||||||
|
(port retiré) : ``""`` pour un nom officiel, ``"-dev"`` pour un
|
||||||
|
nom en ``-dev``, None hors motif ou hors requête.
|
||||||
|
"""
|
||||||
|
hote_req = HOTE_REQUETE.get().split(":")[0]
|
||||||
|
if not hote_req:
|
||||||
|
return None
|
||||||
|
m = _MOTIF_HOTE_PUBLIC.match(hote_req)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return m.group(2) or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _url_depuis_requete(app: str) -> str:
|
||||||
|
"""URL d'``app`` dérivée de l'hôte de la requête ("" si aucune)."""
|
||||||
|
slug = _SLUGS_TOUTES.get(app)
|
||||||
|
if not slug:
|
||||||
|
return ""
|
||||||
|
suffixe = _suffixe_requete()
|
||||||
|
if suffixe is None:
|
||||||
|
return ""
|
||||||
|
return f"https://{slug}{suffixe}.dracodev.net"
|
||||||
|
|
||||||
|
|
||||||
|
def _suffixe_evenements_explicite() -> str | None:
|
||||||
|
"""Suffixe porté par l'URL d'événements **explicite**, ou None.
|
||||||
|
|
||||||
|
Lit ``AGENT_EVENEMENTS_URL`` (environnement puis settings) sans
|
||||||
|
passer par ``agent_evenements_url()`` : pas de récursion avec
|
||||||
|
``agent_url()``, dont c'est une source de dérivation.
|
||||||
|
"""
|
||||||
|
for candidate in (os.environ.get("AGENT_EVENEMENTS_URL", ""),
|
||||||
|
getattr(settings, "agent_evenements_url", "") or ""):
|
||||||
|
candidate = _nettoyer(candidate)
|
||||||
|
if candidate:
|
||||||
|
hote_agent = (urlparse(candidate).hostname or "").lower()
|
||||||
|
m = _MOTIF_HOTE_AGENT.match(hote_agent)
|
||||||
|
return (m.group(1) or "") if m else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resoudre_soeur(env_name: str, settings_attr: str, app: str) -> str:
|
||||||
|
"""Résolution d'une URL sœur, dans l'ordre du chantier :
|
||||||
|
|
||||||
|
explicite > **hôte de la requête** > dérivation par l'agent
|
||||||
|
connu en configuration > officiel.
|
||||||
|
|
||||||
|
La valeur ``settings`` égale au nom officiel est le *défaut* de
|
||||||
|
``app/config.py``, pas un choix explicite : elle ne doit pas
|
||||||
|
bloquer les dérivations. Une valeur settings différente (dev,
|
||||||
|
LAN, domaine propre) reste prioritaire.
|
||||||
|
"""
|
||||||
|
env = _nettoyer(os.environ.get(env_name, ""))
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
cfg = _nettoyer(getattr(settings, settings_attr, "") or "")
|
||||||
|
if cfg and cfg != OFFICIELLES[app]:
|
||||||
|
return cfg
|
||||||
|
depuis_requete = _url_depuis_requete(app)
|
||||||
|
if depuis_requete:
|
||||||
|
return depuis_requete
|
||||||
|
derivee = _url_derivee(app)
|
||||||
|
if derivee:
|
||||||
|
return derivee
|
||||||
|
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_soeur("DRACO_SSO_URL", "draco_sso_url", "obsigate")
|
||||||
|
|
||||||
|
|
||||||
|
def agent_url() -> str:
|
||||||
|
"""Service d'agents (Spark) — base sans chemin.
|
||||||
|
|
||||||
|
Même ordre que les sœurs (la v7.82.3 l'avait laissé sans aucune
|
||||||
|
dérivation — trou n° 1 du mode console mort en dev) : explicite
|
||||||
|
(``DRACO_AGENT_URL`` / settings non officiel) > hôte de la
|
||||||
|
requête > dérivation par l'URL d'événements explicite
|
||||||
|
(``AGENT_EVENEMENTS_URL`` : ``agent-dev…`` → base ``agent-dev…``)
|
||||||
|
> défaut officiel.
|
||||||
|
"""
|
||||||
|
env = _nettoyer(os.environ.get("DRACO_AGENT_URL", ""))
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
cfg = _nettoyer(getattr(settings, "draco_agent_url", "") or "")
|
||||||
|
if cfg and cfg != OFFICIELLES["agent"]:
|
||||||
|
return cfg
|
||||||
|
depuis_requete = _url_depuis_requete("agent")
|
||||||
|
if depuis_requete:
|
||||||
|
return depuis_requete
|
||||||
|
suffixe = _suffixe_evenements_explicite()
|
||||||
|
if suffixe is not None:
|
||||||
|
return f"https://agent{suffixe}.dracodev.net"
|
||||||
|
if cfg:
|
||||||
|
return cfg
|
||||||
|
return OFFICIELLES["agent"]
|
||||||
|
|
||||||
|
|
||||||
|
def tiktak_url() -> str:
|
||||||
|
return _resoudre_soeur("TIKTAK_URL", "tiktak_url", "tiktak")
|
||||||
|
|
||||||
|
|
||||||
|
def kanbante_url() -> str:
|
||||||
|
return _resoudre_soeur("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``) >
|
||||||
|
hôte de la requête (motif fermé) > APP_BASE_URL si elle n'est
|
||||||
|
pas locale > dérivation par l'agent > 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}"
|
||||||
|
# Étape 2 du chantier : l'hôte de la requête — c'est l'origine
|
||||||
|
# que le navigateur utilise déjà pour cette instance, donc
|
||||||
|
# aussi celle du callback SSO (``/auth/draco``) quand rien
|
||||||
|
# d'explicite n'est déclaré.
|
||||||
|
depuis_requete = _url_depuis_requete("flowdeck")
|
||||||
|
if depuis_requete:
|
||||||
|
return depuis_requete
|
||||||
|
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
|
||||||
|
derivee = _url_derivee("flowdeck")
|
||||||
|
if derivee:
|
||||||
|
return derivee
|
||||||
|
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.
|
||||||
|
|
||||||
|
Résolution à l'appel : dans une requête, l'hôte public de la
|
||||||
|
requête fait foi après les variables explicites ; hors requête,
|
||||||
|
la configuration seule (voir le docstring du module).
|
||||||
|
"""
|
||||||
|
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}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.db import get_conn
|
|||||||
from app.services.collection_lifecycle import (
|
from app.services.collection_lifecycle import (
|
||||||
collections_hosted_by_page,
|
collections_hosted_by_page,
|
||||||
delete_collections,
|
delete_collections,
|
||||||
|
detach_page_references,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -71,6 +72,7 @@ def purge_expired(days: int = 30) -> dict:
|
|||||||
"WHERE parent_id=?",
|
"WHERE parent_id=?",
|
||||||
(page_id, page_id),
|
(page_id, page_id),
|
||||||
)
|
)
|
||||||
|
detach_page_references(conn, [page_id])
|
||||||
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
|
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
|
||||||
delete_collections(conn, collections)
|
delete_collections(conn, collections)
|
||||||
purged.append(page_id)
|
purged.append(page_id)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -85,7 +88,11 @@
|
|||||||
}
|
}
|
||||||
if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); return; }
|
if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); return; }
|
||||||
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
|
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
|
||||||
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
|
if (act === "spark") {
|
||||||
|
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||||
|
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (act === "rappel-entete") {
|
if (act === "rappel-entete") {
|
||||||
var bar = document.querySelector(".draco-bar");
|
var bar = document.querySelector(".draco-bar");
|
||||||
if (bar) bar.style.display = "";
|
if (bar) bar.style.display = "";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
aucune feuille de style (composants blancs en thème sombre). -->
|
aucune feuille de style (composants blancs en thème sombre). -->
|
||||||
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
|
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
|
||||||
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ asset_version }}">
|
<link rel="stylesheet" href="/static/css/draco-barre.css?v={{ asset_version }}">
|
||||||
|
<link rel="stylesheet" href="/static/css/draco-panneau.css?v={{ asset_version }}">
|
||||||
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
||||||
<style>
|
<style>
|
||||||
/* ── Mobile responsive (v4.0.2) ── */
|
/* ── Mobile responsive (v4.0.2) ── */
|
||||||
@@ -2870,6 +2871,12 @@
|
|||||||
</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>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
|
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
|
||||||
|
Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
|
||||||
|
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 {
|
||||||
|
--p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
|
||||||
|
--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);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
transform: translateX(105%); transition: transform .28s ease;
|
||||||
|
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-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 {
|
||||||
|
display: flex; align-items: center; gap: 9px;
|
||||||
|
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||||
|
}
|
||||||
|
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
|
||||||
|
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
|
||||||
|
.draco-panneau-hd .fermer {
|
||||||
|
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
|
||||||
|
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
|
||||||
|
}
|
||||||
|
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
|
||||||
|
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
|
||||||
|
.draco-panneau-etat.ok .point { background: #2ea36b; }
|
||||||
|
.draco-panneau-etat.err .point { background: #cf4b32; }
|
||||||
|
/* Barre des discussions (façon Muse) */
|
||||||
|
.draco-disc-bar {
|
||||||
|
display: flex; align-items: center; gap: 7px;
|
||||||
|
padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
align-self: stretch; background: var(--p-appro-fond); border: 1.6px solid #e8a13c;
|
||||||
|
border-radius: 12px; padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.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 .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: var(--p-agent); color: var(--p-texte); }
|
||||||
|
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
|
||||||
|
.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: var(--p-doux); margin: 6px 0 4px; }
|
||||||
|
.draco-auj h5:first-child { margin-top: 0; }
|
||||||
|
.draco-auj ul { margin: 0; padding-left: 17px; }
|
||||||
|
.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-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.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
|
||||||
|
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
|
||||||
|
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
|
||||||
|
.draco-panneau-form { display: flex; gap: 8px; }
|
||||||
|
.draco-panneau-form input {
|
||||||
|
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
|
||||||
|
font: inherit; background: var(--p-champ); color: var(--p-texte);
|
||||||
|
}
|
||||||
|
.draco-panneau-form button {
|
||||||
|
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
|
||||||
|
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
|
||||||
|
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
|
||||||
|
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||||
|
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
|
||||||
|
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
||||||
|
.draco-missions select, .draco-missions input, .draco-missions textarea {
|
||||||
|
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: var(--p-champ); color: var(--p-texte);
|
||||||
|
}
|
||||||
|
.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: var(--p-agent); color: var(--p-texte); }
|
||||||
|
.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 .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||||
|
.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-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-failed { color: #cf4b32; }
|
||||||
|
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
|
||||||
|
.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-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; }
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,826 @@
|
|||||||
|
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2).
|
||||||
|
*
|
||||||
|
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
|
||||||
|
* GET /api/v1/me — identité (cookie draco_session)
|
||||||
|
* POST /api/v1/chat — conversation réelle du noyau (J2) :
|
||||||
|
* LLM DeepSeek + outils MCP des apps ;
|
||||||
|
* 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,
|
||||||
|
* externes J4 : état de configuration)
|
||||||
|
* POST /api/v1/missions — missions longues (J5)
|
||||||
|
* 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
|
||||||
|
* configuré / exécuteur non configuré sont affichés tels quels. */
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
/* 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 convKind = null; // 'principale' | 'parallele'
|
||||||
|
var convTitre = "";
|
||||||
|
var etatConnu = false;
|
||||||
|
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||||
|
var titreEl = null, tiroirEl = null;
|
||||||
|
|
||||||
|
function domaine() {
|
||||||
|
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() {
|
||||||
|
root = document.createElement("aside");
|
||||||
|
root.className = "draco-panneau";
|
||||||
|
root.setAttribute("aria-label", "Spark, l'agent commun");
|
||||||
|
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">' +
|
||||||
|
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
||||||
|
' <div><div class="titre">Spark — agent commun</div>' +
|
||||||
|
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
|
||||||
|
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
|
||||||
|
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="draco-disc-bar">' +
|
||||||
|
' <span class="d-titre">Discussion principale</span>' +
|
||||||
|
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
||||||
|
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
|
||||||
|
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (" + 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">' +
|
||||||
|
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
|
||||||
|
' <button type="button" data-outil="approbations">Approbations</button>' +
|
||||||
|
' <button type="button" data-outil="missions">🚀 Missions</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="draco-panneau-msgs"></div>' +
|
||||||
|
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
|
||||||
|
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
||||||
|
' <button type="submit">Envoyer</button></form>' +
|
||||||
|
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(root);
|
||||||
|
msgsEl = root.querySelector(".draco-panneau-msgs");
|
||||||
|
etatEl = root.querySelector(".draco-panneau-etat");
|
||||||
|
inputEl = root.querySelector("input");
|
||||||
|
sendBtn = root.querySelector("button[type=submit]");
|
||||||
|
titreEl = root.querySelector(".d-titre");
|
||||||
|
tiroirEl = root.querySelector(".draco-discussions");
|
||||||
|
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="approbations"]').addEventListener("click", chargerApprobations);
|
||||||
|
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
||||||
|
root.querySelector("form").addEventListener("submit", envoyer);
|
||||||
|
brancherPoignee(root.querySelector(".draco-poignee"));
|
||||||
|
appliquerTheme();
|
||||||
|
surveillerTheme();
|
||||||
|
appliquerLargeur();
|
||||||
|
window.addEventListener("resize", appliquerLargeur);
|
||||||
|
document.addEventListener("keydown", function (ev) {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ajouterMsg(kind, texte) {
|
||||||
|
var d = document.createElement("div");
|
||||||
|
d.className = "draco-msg " + kind;
|
||||||
|
d.textContent = texte;
|
||||||
|
msgsEl.appendChild(d);
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
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) {
|
||||||
|
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
||||||
|
etatEl.querySelector(".txt").textContent = texte;
|
||||||
|
}
|
||||||
|
function majTitre() {
|
||||||
|
titreEl.textContent = convTitre || "Discussion principale";
|
||||||
|
titreEl.title = titreEl.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifierIdentite() {
|
||||||
|
if (etatConnu) return;
|
||||||
|
fetch(API + "/api/v1/me", { credentials: "include" })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401) {
|
||||||
|
majEtat("err", "Session partagée non reconnue");
|
||||||
|
ajouterMsg("sys", "Le service ne reconnaît pas ta session. Connecte-toi à ObsiGate (session unique draco_session), puis rouvre le panneau. Si le SSO n'est pas encore activé, c'est l'étape d'activation du programme.");
|
||||||
|
etatConnu = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (me) {
|
||||||
|
if (!me) return;
|
||||||
|
etatConnu = true;
|
||||||
|
majEtat("ok", "Connecté au service — " + (me.name || ("utilisateur " + me.sub)));
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
majEtat("err", "Service d'agents injoignable");
|
||||||
|
ajouterMsg("sys", "Le service d'agents (" + API_HOST + ") ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
|
||||||
|
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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() {
|
||||||
|
var bloc = document.createElement("div");
|
||||||
|
bloc.className = "draco-auj";
|
||||||
|
bloc.innerHTML = "<h5>Aujourd'hui — vu du service d'agents</h5><div class='etat-source'>Chargement…</div>";
|
||||||
|
msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
fetch(API + "/api/v1/aujourdhui", { credentials: "include" })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
var h = "<h5>Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "</h5>";
|
||||||
|
var t = d.sources.tiktak;
|
||||||
|
h += "<h5>Tâches du jour — TikTak</h5>";
|
||||||
|
if (t.status === "ok") {
|
||||||
|
if (!t.tasks.length) h += "<div class='etat-source'>Aucune tâche échue aujourd'hui. 🎉</div>";
|
||||||
|
else h += "<ul>" + t.tasks.map(function (x) { return "<li>" + esc(x.title) + "</li>"; }).join("") + "</ul>";
|
||||||
|
} else if (t.status === "non_configure") {
|
||||||
|
h += "<div class='etat-source'>Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).</div>";
|
||||||
|
} else if (t.status === "non_autorise") {
|
||||||
|
h += "<div class='etat-source'>Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).</div>";
|
||||||
|
} else {
|
||||||
|
h += "<div class='etat-source'>Source TikTak " + esc(t.status) + ".</div>";
|
||||||
|
}
|
||||||
|
var k = d.sources.kanbante;
|
||||||
|
h += "<h5>Cartes en Révision — Kanbante</h5>";
|
||||||
|
if (k.status === "ok") {
|
||||||
|
if (!k.review.length) h += "<div class='etat-source'>Rien à valider. 🎉</div>";
|
||||||
|
else h += "<ul>" + k.review.map(function (x) { return "<li>#" + esc(x.number) + " — " + esc(x.title) + "</li>"; }).join("") + "</ul>";
|
||||||
|
} else if (k.status === "non_autorise") {
|
||||||
|
h += "<div class='etat-source'>Source non autorisée : Kanbante exige un jeton d'API pour le service — il arrive avec Kanbante 0.25.0, puis AGENT_MCP_KANBANTE_KEY dans le .env de l'agent.</div>";
|
||||||
|
} else {
|
||||||
|
h += "<div class='etat-source'>Source Kanbante " + esc(k.status) + ".</div>";
|
||||||
|
}
|
||||||
|
bloc.innerHTML = h;
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
bloc.innerHTML = "<h5>Aujourd'hui</h5><div class='etat-source'>Service injoignable.</div>";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function chargerApprobations() {
|
||||||
|
fetch(API + "/api/v1/approvals?status=pending", { credentials: "include" })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.approvals.length) { ajouterMsg("sys", "Aucune approbation en attente."); return; }
|
||||||
|
d.approvals.forEach(function (a) {
|
||||||
|
var bloc = document.createElement("div");
|
||||||
|
bloc.className = "draco-appro";
|
||||||
|
bloc.innerHTML = "<div class='titre'>Approbation demandée — " + esc(a.action) + "</div>" +
|
||||||
|
"<div class='resume'>" + esc(a.summary) + "</div>" +
|
||||||
|
"<div class='btns'><button type='button' class='go'>Approuver</button>" +
|
||||||
|
"<button type='button' class='non'>Rejeter</button></div>";
|
||||||
|
bloc.querySelector(".go").addEventListener("click", function () { decider(a.id, "approved", bloc); });
|
||||||
|
bloc.querySelector(".non").addEventListener("click", function () { decider(a.id, "rejected", bloc); });
|
||||||
|
msgsEl.appendChild(bloc);
|
||||||
|
});
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
})
|
||||||
|
.catch(function () { ajouterMsg("sys", "Approbations : service injoignable."); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function decider(id, decision, bloc) {
|
||||||
|
fetch(API + "/api/v1/approvals/" + id + "/decide", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ decision: decision })
|
||||||
|
})
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
bloc.style.opacity = ".55";
|
||||||
|
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 ?)."); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Missions longues (J5) ───────────────────────────────────
|
||||||
|
* Sélecteur d'exécuteur alimenté par le registre du service :
|
||||||
|
* les externes non configurés sont montrés mais désactivés —
|
||||||
|
* jamais un lancement qui simule. La liste se rafraîchit à
|
||||||
|
* l'ouverture et après chaque lancement/annulation. */
|
||||||
|
var STATUTS_MISSION = {
|
||||||
|
queued: "En file", running: "En cours…", paused_question: "Question en attente",
|
||||||
|
succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée"
|
||||||
|
};
|
||||||
|
|
||||||
|
function ouvrirMissions() {
|
||||||
|
var bloc = document.createElement("div");
|
||||||
|
bloc.className = "draco-missions";
|
||||||
|
bloc.innerHTML =
|
||||||
|
"<h5>Missions longues</h5>" +
|
||||||
|
"<label>Exécuteur</label><select class='m-exec'><option>Chargement…</option></select>" +
|
||||||
|
"<label>Titre</label><input type='text' class='m-titre' placeholder='Ex. Veille des projets du matin'>" +
|
||||||
|
"<label>Consigne</label><textarea class='m-prompt' rows='3' placeholder='Ce que l\u2019exécuteur doit faire…'></textarea>" +
|
||||||
|
"<label>Carte Kanbante liée (n°, optionnel)</label><input type='number' class='m-carte' min='1' placeholder='—'>" +
|
||||||
|
"<div class='btns'><button type='button' class='go'>Lancer la mission</button>" +
|
||||||
|
"<button type='button' class='rafraichir'>Rafraîchir la liste</button></div>" +
|
||||||
|
"<div class='m-liste'><div class='etat-source'>Chargement des missions…</div></div>";
|
||||||
|
msgsEl.appendChild(bloc);
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
fetch(API + "/api/v1/executors", { credentials: "include" })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
var sel = bloc.querySelector(".m-exec");
|
||||||
|
sel.innerHTML = "";
|
||||||
|
d.executors.forEach(function (e) {
|
||||||
|
if (e.family !== "sous-agent" && e.family !== "externe") return;
|
||||||
|
var opt = document.createElement("option");
|
||||||
|
opt.value = e.id;
|
||||||
|
opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") +
|
||||||
|
(e.status === "non_configure" ? " — non configuré" : "");
|
||||||
|
if (e.status === "non_configure") opt.disabled = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (!sel.options.length) sel.innerHTML = "<option value=''>Aucun exécuteur lançable</option>";
|
||||||
|
})
|
||||||
|
.catch(function () { bloc.querySelector(".m-exec").innerHTML = "<option value=''>Registre injoignable</option>"; });
|
||||||
|
bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); });
|
||||||
|
bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); });
|
||||||
|
chargerMissions(bloc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chargerMissions(bloc) {
|
||||||
|
var zone = bloc.querySelector(".m-liste");
|
||||||
|
fetch(API + "/api/v1/missions", { credentials: "include" })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.missions.length) { zone.innerHTML = "<div class='etat-source'>Aucune mission pour le moment.</div>"; return; }
|
||||||
|
zone.innerHTML = "";
|
||||||
|
d.missions.slice(0, 10).forEach(function (m) {
|
||||||
|
var ligne = document.createElement("div");
|
||||||
|
ligne.className = "m-item";
|
||||||
|
var entete = document.createElement("div");
|
||||||
|
entete.className = "m-entete";
|
||||||
|
entete.innerHTML = "<b>" + esc(m.title) + "</b>" +
|
||||||
|
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
|
||||||
|
ligne.appendChild(entete);
|
||||||
|
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") {
|
||||||
|
var btn = document.createElement("button");
|
||||||
|
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
fetch(API + "/api/v1/missions/" + m.id + "/cancel", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" }, body: "{}"
|
||||||
|
}).then(function () { chargerMissions(bloc); })
|
||||||
|
.catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); });
|
||||||
|
});
|
||||||
|
ligne.appendChild(btn);
|
||||||
|
}
|
||||||
|
zone.appendChild(ligne);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () { zone.innerHTML = "<div class='etat-source'>Missions : service injoignable.</div>"; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function lancerMission(bloc) {
|
||||||
|
var executor = bloc.querySelector(".m-exec").value;
|
||||||
|
var titre = bloc.querySelector(".m-titre").value.trim();
|
||||||
|
var prompt = bloc.querySelector(".m-prompt").value.trim();
|
||||||
|
var carte = bloc.querySelector(".m-carte").value;
|
||||||
|
if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; }
|
||||||
|
if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; }
|
||||||
|
var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() };
|
||||||
|
if (carte) corps.kanbante_number = parseInt(carte, 10);
|
||||||
|
fetch(API + "/api/v1/missions", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(corps)
|
||||||
|
})
|
||||||
|
.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();
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions.");
|
||||||
|
chargerMissions(bloc);
|
||||||
|
})
|
||||||
|
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function envoyer(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
var texte = inputEl.value.trim();
|
||||||
|
if (!texte) return;
|
||||||
|
inputEl.value = "";
|
||||||
|
ajouterMsg("user", texte);
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
var attente = ajouterMsg("agent", "Spark réfléchit…");
|
||||||
|
fetch(API + "/api/v1/chat", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401) {
|
||||||
|
attente.textContent = "Le service ne reconnaît pas ta session partagée (401). Connecte-toi à ObsiGate puis réessaie.";
|
||||||
|
majEtat("err", "Session partagée non reconnue");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
return r.json().catch(function () { return null; }).then(function (e) {
|
||||||
|
attente.textContent = (e && e.detail) ? e.detail :
|
||||||
|
"Le service a répondu HTTP " + r.status + " — réessaie dans un instant.";
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d) return;
|
||||||
|
convId = d.conversation_id;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
if (d.tools_used && d.tools_used.length) {
|
||||||
|
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
|
||||||
|
}
|
||||||
|
majEtat("ok", "Connecté au service — domaine " + d.domain);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
|
||||||
|
majEtat("err", "Service d'agents injoignable");
|
||||||
|
})
|
||||||
|
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ouvrir() {
|
||||||
|
if (!root) { construire(); demarrerDiscussions(); }
|
||||||
|
root.classList.add("open");
|
||||||
|
appliquerTheme();
|
||||||
|
verifierIdentite();
|
||||||
|
setTimeout(function () { inputEl.focus(); }, 300);
|
||||||
|
}
|
||||||
|
function fermer() { if (root) root.classList.remove("open"); }
|
||||||
|
|
||||||
|
window.DRACO_PANNEAU = {
|
||||||
|
open: ouvrir,
|
||||||
|
close: fermer,
|
||||||
|
toggle: function () {
|
||||||
|
if (root && root.classList.contains("open")) fermer(); else ouvrir();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -291,8 +291,13 @@ def test_csp_no_cdn_and_vendor(client):
|
|||||||
import re as _re
|
import re as _re
|
||||||
|
|
||||||
conn = _re.search(r"connect-src ([^;]*);", csp).group(1)
|
conn = _re.search(r"connect-src ([^;]*);", csp).group(1)
|
||||||
assert conn == "'self' ws://testserver wss://testserver", conn
|
# Exception Fusion (2026-10-21, 7.75.0) : le panneau Spark parle au
|
||||||
assert " https:" not in conn and not conn.startswith("https:"), conn
|
# service d'agents commun — seul hôte externe nommé autorisé.
|
||||||
|
assert conn == "'self' https://agent.dracodev.net ws://testserver wss://testserver", conn
|
||||||
|
# La source générique « https: » (tous hôtes) reste interdite ; seul
|
||||||
|
# l'hôte nommé du service d'agents est admis (voir l'assertion
|
||||||
|
# d'égalité ci-dessus, qui verrouille la liste complète).
|
||||||
|
assert "https:" not in conn.split(), conn
|
||||||
|
|
||||||
# vues chart/map : références locales (aucun CDN — A28 : le fichier
|
# vues chart/map : références locales (aucun CDN — A28 : le fichier
|
||||||
# collections.py est devenu un package, on balaie tous ses modules)
|
# collections.py est devenu un package, on balaie tous ses modules)
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"""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_derivation_dev_depuis_agent_evenements_seul(monkeypatch):
|
||||||
|
# Cas réel de la dev : seul AGENT_EVENEMENTS_URL est posé —
|
||||||
|
# les quatre URLs sœurs servies suivent en ``-dev``, sans
|
||||||
|
# déclarer quatre variables de plus. Gitea ne change pas.
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"AGENT_EVENEMENTS_URL",
|
||||||
|
"https://agent-dev.dracodev.net/api/v1/evenements")
|
||||||
|
urls = draco_urls.toutes()
|
||||||
|
assert urls["obsigate"] == "https://og-dev.dracodev.net"
|
||||||
|
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
|
||||||
|
assert urls["tiktak"] == "https://tiktak-dev.dracodev.net"
|
||||||
|
assert urls["kanbante"] == "https://kanbante-dev.dracodev.net"
|
||||||
|
assert urls["gitea"] == "https://git.dracodev.net"
|
||||||
|
# Les accesseurs individuels partagent la même résolution.
|
||||||
|
assert draco_urls.obsigate_url() == "https://og-dev.dracodev.net"
|
||||||
|
assert draco_urls.flowdeck_url() == "https://flowdeck-dev.dracodev.net"
|
||||||
|
assert draco_urls.tiktak_url() == "https://tiktak-dev.dracodev.net"
|
||||||
|
assert draco_urls.kanbante_url() == "https://kanbante-dev.dracodev.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_derivation_explicite_gagne_sur_derivation(monkeypatch):
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"AGENT_EVENEMENTS_URL",
|
||||||
|
"https://agent-dev.dracodev.net/api/v1/evenements")
|
||||||
|
# Explicites (contrat inchangé) : elles priment sur la dérivation,
|
||||||
|
# y compris quand elles pointent le nom officiel.
|
||||||
|
monkeypatch.setenv("DRACO_SSO_URL", "https://og.dracodev.net")
|
||||||
|
monkeypatch.setenv("TIKTAK_URL", "https://tiktak.example.net")
|
||||||
|
urls = draco_urls.toutes()
|
||||||
|
assert urls["obsigate"] == "https://og.dracodev.net"
|
||||||
|
assert urls["tiktak"] == "https://tiktak.example.net"
|
||||||
|
# Non déclarées : dérivées.
|
||||||
|
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
|
||||||
|
assert urls["kanbante"] == "https://kanbante-dev.dracodev.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_derivation_hote_agent_hors_motif_defauts_officiels(monkeypatch):
|
||||||
|
# Autre domaine, LAN, IP : aucune dérivation, jamais un lien deviné.
|
||||||
|
for url_agent in (
|
||||||
|
"https://agent.example.com/api/v1/evenements",
|
||||||
|
"https://agent-dev.example.net/api/v1/evenements",
|
||||||
|
"https://monagent-dev.dracodev.net/api/v1/evenements",
|
||||||
|
"https://agent-dev.dracodev.net.evil.com/api/v1/evenements",
|
||||||
|
"http://192.168.30.101:8790/api/v1/evenements",
|
||||||
|
"http://127.0.0.1:8790/api/v1/evenements",
|
||||||
|
"http://agent-dev.local:8790/api/v1/evenements",
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_URL", url_agent)
|
||||||
|
urls = draco_urls.toutes()
|
||||||
|
assert urls["obsigate"] == "https://og.dracodev.net", url_agent
|
||||||
|
assert urls["flowdeck"] == "https://flowdeck.dracodev.net", url_agent
|
||||||
|
assert urls["tiktak"] == "https://tiktak.dracodev.net", url_agent
|
||||||
|
assert urls["kanbante"] == "https://kanbante.dracodev.net", url_agent
|
||||||
|
|
||||||
|
|
||||||
|
def test_derivation_agent_officiel_suffixe_vide(monkeypatch):
|
||||||
|
# Hôte agent officiel : suffixe vide, les sœurs restent officielles.
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"AGENT_EVENEMENTS_URL", "https://agent.dracodev.net/api/v1/evenements")
|
||||||
|
urls = draco_urls.toutes()
|
||||||
|
assert urls["obsigate"] == "https://og.dracodev.net"
|
||||||
|
assert urls["flowdeck"] == "https://flowdeck.dracodev.net"
|
||||||
|
assert urls["tiktak"] == "https://tiktak.dracodev.net"
|
||||||
|
assert urls["kanbante"] == "https://kanbante.dracodev.net"
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
# ── v7.82.4 — résolution par l'hôte de la requête ─────────────────────
|
||||||
|
# Correctif du mode console mort sur flowdeck-dev : l'agent n'avait
|
||||||
|
# aucune dérivation (v7.82.3) et rien ne lisait l'hôte de la requête.
|
||||||
|
# Ordre : explicite > hôte public de la requête (motif fermé) >
|
||||||
|
# dérivation par l'agent connu en configuration > officiel.
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def avec_hote():
|
||||||
|
"""Pose l'hôte de requête comme le middleware CSP le fait."""
|
||||||
|
jetons = []
|
||||||
|
|
||||||
|
def poser(hote):
|
||||||
|
jetons.append(draco_urls.HOTE_REQUETE.set(hote))
|
||||||
|
|
||||||
|
yield poser
|
||||||
|
for jeton in reversed(jetons):
|
||||||
|
draco_urls.HOTE_REQUETE.reset(jeton)
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_url_derivee_depuis_evenements_seul(monkeypatch):
|
||||||
|
# Trou n° 1 de la v7.82.3 : seul AGENT_EVENEMENTS_URL posé (cas
|
||||||
|
# réel de la dev) → l'agent lui-même suit en ``-dev``, pas
|
||||||
|
# seulement les sœurs.
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"AGENT_EVENEMENTS_URL",
|
||||||
|
"https://agent-dev.dracodev.net/api/v1/evenements")
|
||||||
|
assert draco_urls.agent_url() == "https://agent-dev.dracodev.net"
|
||||||
|
assert draco_urls.agent_evenements_url() == \
|
||||||
|
"https://agent-dev.dracodev.net/api/v1/evenements"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hote_requete_dev_toutes_urls_sans_env(avec_hote):
|
||||||
|
# Aucune variable d'environnement (fixture _env_propre) : l'hôte
|
||||||
|
# public suffit — sœurs ET agent en ``-dev``, Gitea inchangée.
|
||||||
|
avec_hote("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"
|
||||||
|
assert urls["gitea"] == "https://git.dracodev.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hote_requete_quelconque_des_cinq_slugs(avec_hote):
|
||||||
|
# L'environnement est celui de l'installation, pas de l'app
|
||||||
|
# demandée : un hôte ``agent-dev`` ou ``og-dev`` vaut ``-dev``
|
||||||
|
# pour toutes, port éventuel ignoré.
|
||||||
|
for hote in ("agent-dev.dracodev.net", "og-dev.dracodev.net:443",
|
||||||
|
"tiktak-dev.dracodev.net", "kanbante-dev.dracodev.net"):
|
||||||
|
avec_hote(hote)
|
||||||
|
assert draco_urls.agent_url() == "https://agent-dev.dracodev.net", hote
|
||||||
|
assert draco_urls.obsigate_url() == "https://og-dev.dracodev.net", hote
|
||||||
|
|
||||||
|
|
||||||
|
def test_hote_requete_prod_noms_officiels(avec_hote):
|
||||||
|
avec_hote("flowdeck.dracodev.net")
|
||||||
|
urls = draco_urls.toutes()
|
||||||
|
assert urls["obsigate"] == "https://og.dracodev.net"
|
||||||
|
assert urls["agent"] == "https://agent.dracodev.net"
|
||||||
|
assert urls["flowdeck"] == "https://flowdeck.dracodev.net"
|
||||||
|
assert all("-dev" not in u for u in urls.values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_hote_requete_motif_ferme_aucune_derivation(avec_hote):
|
||||||
|
# Jamais de suffixe inventé depuis un hôte arbitraire.
|
||||||
|
for hote in ("evil-dev.dracodev.net",
|
||||||
|
"flowdeck-dev.dracodev.net.evil.com",
|
||||||
|
"monflowdeck-dev.dracodev.net",
|
||||||
|
"flowdeckdev.dracodev.net",
|
||||||
|
"flowdeck-dev.example.net"):
|
||||||
|
avec_hote(hote)
|
||||||
|
urls = draco_urls.toutes()
|
||||||
|
assert urls["agent"] == "https://agent.dracodev.net", hote
|
||||||
|
assert urls["obsigate"] == "https://og.dracodev.net", hote
|
||||||
|
assert urls["flowdeck"] == "https://flowdeck.dracodev.net", hote
|
||||||
|
|
||||||
|
|
||||||
|
def test_hote_requete_lan_repli_configuration(avec_hote, monkeypatch):
|
||||||
|
# Hors motif (LAN, IP) : repli configuration/défauts, comme
|
||||||
|
# hors requête — ici la dérivation par AGENT_EVENEMENTS_URL.
|
||||||
|
avec_hote("192.168.30.101:8080")
|
||||||
|
urls = draco_urls.toutes()
|
||||||
|
assert urls["agent"] == "https://agent.dracodev.net"
|
||||||
|
assert urls["obsigate"] == "https://og.dracodev.net"
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"AGENT_EVENEMENTS_URL",
|
||||||
|
"https://agent-dev.dracodev.net/api/v1/evenements")
|
||||||
|
urls = draco_urls.toutes()
|
||||||
|
assert urls["agent"] == "https://agent-dev.dracodev.net"
|
||||||
|
assert urls["obsigate"] == "https://og-dev.dracodev.net"
|
||||||
|
assert urls["tiktak"] == "https://tiktak-dev.dracodev.net"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hote_requete_explicite_gagne_sur_hote(avec_hote, monkeypatch):
|
||||||
|
avec_hote("flowdeck-dev.dracodev.net")
|
||||||
|
# Explicites prioritaires, y compris quand elles pointent le
|
||||||
|
# nom officiel ou un domaine propre.
|
||||||
|
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent.dracodev.net")
|
||||||
|
monkeypatch.setenv("DRACO_SSO_URL", "https://og.example.net")
|
||||||
|
urls = draco_urls.toutes()
|
||||||
|
assert urls["agent"] == "https://agent.dracodev.net"
|
||||||
|
assert urls["obsigate"] == "https://og.example.net"
|
||||||
|
# Non déclarées : l'hôte de la requête fait foi.
|
||||||
|
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_hote_requete_bouton_sso_tout_dev(avec_hote):
|
||||||
|
from app.routers.auth import _sso_login_url
|
||||||
|
|
||||||
|
avec_hote("flowdeck-dev.dracodev.net")
|
||||||
|
# Émettrice via DRACO_SSO_URL résolu par l'hôte + callback =
|
||||||
|
# propre origine résolue de l'instance.
|
||||||
|
assert _sso_login_url() == (
|
||||||
|
"https://og-dev.dracodev.net/sso/authorize?return_to="
|
||||||
|
"https%3A%2F%2Fflowdeck-dev.dracodev.net%2Fauth%2Fdraco")
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_dev_sans_env_tout_en_dev(client):
|
||||||
|
# Requête complète, aucune variable d'environnement : barre,
|
||||||
|
# panneau (config de l'iframe console), CSP et SSO en ``-dev``.
|
||||||
|
r = client.get("/workspaces",
|
||||||
|
headers={"Host": "flowdeck-dev.dracodev.net"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
html = r.text
|
||||||
|
# Barre inter-apps (les quatre sœurs).
|
||||||
|
for soeur in ("og-dev", "flowdeck-dev", "tiktak-dev", "kanbante-dev"):
|
||||||
|
assert f'href="https://{soeur}.dracodev.net/"' in html
|
||||||
|
# Panneau : la source de l'iframe console vient de cette config.
|
||||||
|
assert 'agent_url: "https://agent-dev.dracodev.net"' in html
|
||||||
|
# CSP de la MÊME réponse : même origine agent résolue.
|
||||||
|
csp = r.headers.get("content-security-policy", "")
|
||||||
|
assert "frame-src 'self' https://agent-dev.dracodev.net" in csp
|
||||||
|
assert "connect-src 'self' https://agent-dev.dracodev.net" in csp
|
||||||
|
# Aucun nom officiel nu ne subsiste dans la réponse.
|
||||||
|
for officiel in ("https://og.dracodev.net", "https://agent.dracodev.net",
|
||||||
|
"https://tiktak.dracodev.net",
|
||||||
|
"https://kanbante.dracodev.net",
|
||||||
|
"https://flowdeck.dracodev.net"):
|
||||||
|
assert officiel not in html.replace("-dev.dracodev.net", ""), officiel
|
||||||
|
assert officiel not in csp.replace("-dev.dracodev.net", ""), officiel
|
||||||
|
# Bouton SSO de la page de connexion, même requête type.
|
||||||
|
r = client.get("/auth/login?provider=local",
|
||||||
|
headers={"Host": "flowdeck-dev.dracodev.net"})
|
||||||
|
assert ("https://og-dev.dracodev.net/sso/authorize?return_to="
|
||||||
|
"https%3A%2F%2Fflowdeck-dev.dracodev.net%2Fauth%2Fdraco") in r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_prod_noms_officiels(client):
|
||||||
|
r = client.get("/workspaces",
|
||||||
|
headers={"Host": "flowdeck.dracodev.net"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert 'agent_url: "https://agent.dracodev.net"' in r.text
|
||||||
|
assert 'href="https://og.dracodev.net/"' in r.text
|
||||||
|
csp = r.headers.get("content-security-policy", "")
|
||||||
|
assert "frame-src 'self' https://agent.dracodev.net" in csp
|
||||||
|
assert "-dev.dracodev.net" not in r.text
|
||||||
|
assert "-dev.dracodev.net" not in csp
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_lan_repli_defauts(client):
|
||||||
|
r = client.get("/workspaces",
|
||||||
|
headers={"Host": "192.168.30.101:8080"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert 'agent_url: "https://agent.dracodev.net"' in r.text
|
||||||
|
csp = r.headers.get("content-security-policy", "")
|
||||||
|
assert "frame-src 'self' https://agent.dracodev.net" in csp
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_explicite_gagne_sur_hote(client, monkeypatch):
|
||||||
|
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent.dracodev.net")
|
||||||
|
r = client.get("/workspaces",
|
||||||
|
headers={"Host": "flowdeck-dev.dracodev.net"})
|
||||||
|
assert 'agent_url: "https://agent.dracodev.net"' in r.text
|
||||||
|
csp = r.headers.get("content-security-policy", "")
|
||||||
|
assert "frame-src 'self' https://agent.dracodev.net;" in csp
|
||||||
|
# Le reste suit l'hôte de la requête.
|
||||||
|
assert 'href="https://og-dev.dracodev.net/"' in r.text
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""Fusion — serveur MCP Flowdeck (branchement du service d'agents).
|
||||||
|
|
||||||
|
POST /mcp, authentifié par les jetons d'API personnels existants
|
||||||
|
(api_tokens). Le service d'agents lit et écrit (création/mise à
|
||||||
|
jour de pages, création de collections — aucune suppression) au
|
||||||
|
nom du propriétaire du jeton.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def jeton(client):
|
||||||
|
brut = "fd_test_token_pour_mcp"
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def donnees(client):
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO collections (name, description, icon) VALUES ('Projet Alpha', 'Le projet de test', '🚀')")
|
||||||
|
cid = cur.lastrowid
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO collection_pages (collection_id, title, icon, position, property_values_json) "
|
||||||
|
"VALUES (?, 'Page de cadrage', 'file', 1, ?)",
|
||||||
|
(cid, json.dumps({"statut": "en cours"})))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO collection_pages (collection_id, title, icon, position, property_values_json) "
|
||||||
|
"VALUES (?, 'Revue hebdo', 'file', 2, '{}')", (cid,))
|
||||||
|
conn.commit()
|
||||||
|
return cid
|
||||||
|
|
||||||
|
|
||||||
|
def _rpc(client, jeton, rid, methode, params=None):
|
||||||
|
h = {"Authorization": f"Bearer {jeton}"} if jeton else {}
|
||||||
|
corps = {"jsonrpc": "2.0", "id": rid, "method": methode}
|
||||||
|
if params is not None:
|
||||||
|
corps["params"] = params
|
||||||
|
return client.post("/mcp", json=corps, headers=h)
|
||||||
|
|
||||||
|
|
||||||
|
def _appel(client, jeton, rid, nom, args=None):
|
||||||
|
r = _rpc(client, jeton, rid, "tools/call", {"name": nom, "arguments": args or {}})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return json.loads(r.json()["result"]["content"][0]["text"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_sans_jeton_401(client):
|
||||||
|
r = _rpc(client, None, 1, "tools/list")
|
||||||
|
assert r.status_code == 401
|
||||||
|
r = _rpc(client, "mauvais-jeton", 1, "tools/list")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_handshake_et_outils(client, jeton):
|
||||||
|
r = _rpc(client, jeton, 1, "initialize", {})
|
||||||
|
assert r.json()["result"]["serverInfo"]["name"] == "flowdeck"
|
||||||
|
r = _rpc(client, jeton, 2, "tools/list")
|
||||||
|
noms = [t["name"] for t in r.json()["result"]["tools"]]
|
||||||
|
assert noms == ["list_workspaces", "get_workspace",
|
||||||
|
"list_collections", "get_collection", "get_page",
|
||||||
|
"search_pages", "list_repos", "create_page",
|
||||||
|
"update_page", "create_collection"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_workspaces(client, jeton):
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (1, 'tester')")
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO workspaces (name, owner_id) VALUES ('Techno', 1)")
|
||||||
|
wid = cur.lastrowid
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO pages (workspace, workspace_id, title, parent_section) "
|
||||||
|
"VALUES ('Techno', ?, 'Page racine', 'Private')", (wid,))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO pages (workspace, workspace_id, title, parent_section, deleted_at) "
|
||||||
|
"VALUES ('Techno', ?, 'Page corbeille', 'Private', '2026-10-11 10:00:00')", (wid,))
|
||||||
|
conn.commit()
|
||||||
|
ws = _appel(client, jeton, 70, "list_workspaces")
|
||||||
|
techno = [w for w in ws if w["name"] == "Techno"]
|
||||||
|
assert techno and techno[0]["page_count"] == 1 # la corbeille ne compte pas
|
||||||
|
detail = _appel(client, jeton, 71, "get_workspace", {"id": wid})
|
||||||
|
assert detail["workspace"]["name"] == "Techno"
|
||||||
|
assert [p["title"] for p in detail["pages"]] == ["Page racine"]
|
||||||
|
res = _appel(client, jeton, 72, "get_workspace", {"id": 99999})
|
||||||
|
assert "introuvable" in res["erreur"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_collections_et_pages(client, jeton, donnees):
|
||||||
|
cols = _appel(client, jeton, 1, "list_collections")
|
||||||
|
alpha = [c for c in cols if c["name"] == "Projet Alpha"]
|
||||||
|
assert alpha and alpha[0]["page_count"] == 2
|
||||||
|
detail = _appel(client, jeton, 2, "get_collection", {"id": donnees})
|
||||||
|
assert detail["collection"]["name"] == "Projet Alpha"
|
||||||
|
titres = [p["title"] for p in detail["pages"]]
|
||||||
|
assert titres == ["Page de cadrage", "Revue hebdo"]
|
||||||
|
assert detail["pages"][0]["properties"] == {"statut": "en cours"}
|
||||||
|
res = _appel(client, jeton, 3, "search_pages", {"query": "cadrage"})
|
||||||
|
assert [p["title"] for p in res] == ["Page de cadrage"]
|
||||||
|
res = _appel(client, jeton, 4, "get_collection", {"id": 99999})
|
||||||
|
assert "introuvable" in res["erreur"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_create_page_puis_relecture(client, jeton, donnees):
|
||||||
|
creee = _appel(client, jeton, 10, "create_page", {
|
||||||
|
"collection_id": donnees, "title": "Page créée via MCP",
|
||||||
|
"icon": "file", "properties": {"statut": "à faire", "priorite": "haute"}})
|
||||||
|
assert creee["title"] == "Page créée via MCP"
|
||||||
|
assert creee["collection_id"] == donnees
|
||||||
|
assert creee["properties"] == {"statut": "à faire", "priorite": "haute"}
|
||||||
|
detail = _appel(client, jeton, 11, "get_collection", {"id": donnees})
|
||||||
|
titres = [p["title"] for p in detail["pages"]]
|
||||||
|
assert "Page créée via MCP" in titres
|
||||||
|
relue = [p for p in detail["pages"] if p["id"] == creee["id"]][0]
|
||||||
|
assert relue["properties"] == {"statut": "à faire", "priorite": "haute"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_update_page_titre_et_proprietes(client, jeton, donnees):
|
||||||
|
creee = _appel(client, jeton, 20, "create_page", {
|
||||||
|
"collection_id": donnees, "title": "Titre initial",
|
||||||
|
"properties": {"statut": "brouillon"}})
|
||||||
|
maj = _appel(client, jeton, 21, "update_page", {
|
||||||
|
"id": creee["id"], "title": "Titre mis à jour",
|
||||||
|
"properties": {"statut": "publié"}})
|
||||||
|
assert maj["title"] == "Titre mis à jour"
|
||||||
|
# properties remplace property_values_json (pas de fusion)
|
||||||
|
assert maj["properties"] == {"statut": "publié"}
|
||||||
|
relue = _appel(client, jeton, 22, "get_page", {"id": creee["id"]})
|
||||||
|
assert relue["title"] == "Titre mis à jour"
|
||||||
|
assert relue["properties"] == {"statut": "publié"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_create_collection(client, jeton):
|
||||||
|
creee = _appel(client, jeton, 30, "create_collection", {
|
||||||
|
"name": "Projet Bêta", "description": "Créée via MCP", "icon": "🧪"})
|
||||||
|
assert creee["name"] == "Projet Bêta"
|
||||||
|
assert creee["description"] == "Créée via MCP"
|
||||||
|
assert creee["icon"] == "🧪"
|
||||||
|
cols = _appel(client, jeton, 31, "list_collections")
|
||||||
|
beta = [c for c in cols if c["name"] == "Projet Bêta"]
|
||||||
|
assert beta and beta[0]["id"] == creee["id"]
|
||||||
|
assert beta[0]["page_count"] == 0
|
||||||
|
detail = _appel(client, jeton, 32, "get_collection", {"id": creee["id"]})
|
||||||
|
assert detail["collection"]["name"] == "Projet Bêta"
|
||||||
|
assert detail["pages"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_erreurs_ecritures(client, jeton, donnees):
|
||||||
|
res = _appel(client, jeton, 40, "create_page", {
|
||||||
|
"collection_id": 99999, "title": "Orpheline"})
|
||||||
|
assert "introuvable" in res["erreur"]
|
||||||
|
res = _appel(client, jeton, 41, "create_page", {
|
||||||
|
"collection_id": donnees, "title": " "})
|
||||||
|
assert "erreur" in res
|
||||||
|
res = _appel(client, jeton, 42, "update_page", {
|
||||||
|
"id": 99999, "title": "Fantôme"})
|
||||||
|
assert "introuvable" in res["erreur"]
|
||||||
|
res = _appel(client, jeton, 43, "create_collection", {"name": " "})
|
||||||
|
assert "erreur" in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_ecriture_sans_jeton_401(client, donnees):
|
||||||
|
r = _rpc(client, None, 50, "tools/call",
|
||||||
|
{"name": "create_page",
|
||||||
|
"arguments": {"collection_id": donnees, "title": "Refusée"}})
|
||||||
|
assert r.status_code == 401
|
||||||
|
r = _rpc(client, "mauvais-jeton", 51, "tools/call",
|
||||||
|
{"name": "create_collection", "arguments": {"name": "Refusée"}})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_mcp_client_bearer_sans_session_ni_csrf(client, jeton, donnees):
|
||||||
|
"""Régression 7.76.1 : un vrai client MCP est machine-à-machine
|
||||||
|
(Bearer seul, ni session ni jeton CSRF) — exactement le service
|
||||||
|
d'agents. Le client de test habituel porte une session + un CSRF,
|
||||||
|
ce qui masquait le 403 du middleware en production."""
|
||||||
|
from tests.conftest import anon
|
||||||
|
anon(client)
|
||||||
|
r = _rpc(client, jeton, 60, "initialize", {})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
cols = _appel(client, jeton, 61, "list_collections")
|
||||||
|
assert any(c["name"] == "Projet Alpha" for c in cols)
|
||||||
|
r = _rpc(client, None, 62, "tools/list")
|
||||||
|
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):
|
||||||
|
r = client.get("/mcp")
|
||||||
|
assert r.status_code == 200
|
||||||
|
infos = r.json()
|
||||||
|
assert infos["read_only"] is False
|
||||||
|
assert "create_page" in infos["tools"]
|
||||||
|
assert "update_page" in infos["tools"]
|
||||||
|
assert "create_collection" in infos["tools"]
|
||||||
@@ -32,6 +32,32 @@ def _row(page_id):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def test_permanent_delete_page_referenced_by_favorites_and_recents(client):
|
||||||
|
"""Régression 7.77.0 (cas réel de Bruno, 2026-10-22) : une page
|
||||||
|
réelle — ouverte (recents) et/ou favorite — ne pouvait plus être
|
||||||
|
supprimée définitivement : favorites.page_id et recents.page_id
|
||||||
|
référencent pages(id) sans CASCADE, PRAGMA foreign_keys=ON →
|
||||||
|
IntegrityError → 500 « Delete failed », et Empty Trash (une seule
|
||||||
|
transaction) échouait en bloc. Les pages synthétiques des anciens
|
||||||
|
tests n'avaient ni favori ni récent : elles passaient."""
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
login_test_client(client)
|
||||||
|
pid = _mk_page(title="Page réelle de Bruno")
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("INSERT INTO favorites (user_id, page_id, position) VALUES (1, ?, 0)", (pid,))
|
||||||
|
conn.execute("INSERT INTO recents (user_id, page_id, workspace) VALUES (1, ?, 'x')", (pid,))
|
||||||
|
conn.commit()
|
||||||
|
r = client.post(f"/api/pages/{pid}/trash")
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = client.delete(f"/board/api/trash/{pid}")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert _row(pid) is None
|
||||||
|
with get_conn() as conn:
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM favorites WHERE page_id=?", (pid,)).fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM recents WHERE page_id=?", (pid,)).fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_trash_requires_session(client):
|
def test_trash_requires_session(client):
|
||||||
pid = _mk_page()
|
pid = _mk_page()
|
||||||
anon_csrf(client) # anonyme mais CSRF valide → on veut le 401, pas le 403
|
anon_csrf(client) # anonyme mais CSRF valide → on veut le 401, pas le 403
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user