recherche : onglet Console Spark sur GET /api/v1/recherche + barre /recherche re-vendangée (alignement) #48

Merged
bruno merged 4 commits from align-recherche-console into main 2026-10-28 15:04:33 -04:00
4 changed files with 189 additions and 45 deletions
+12 -7
View File
@@ -966,12 +966,15 @@ if FRONTEND_DIR.exists():
frontend/recherche.html est servi avec les jetons {{URL_*}} frontend/recherche.html est servi avec les jetons {{URL_*}}
remplacés par urls_publiques() (même contrat que le fragment remplacés par urls_publiques() (même contrat que le fragment
de barre) et le nonce CSP frais ; la réponse pose sa propre de barre, jeton {{URL_AGENT}} inclus pour l'onglet Console
CSP (préservée par SecurityHeadersMiddleware, qui ne la pose Spark de la barre re-vendangée) et le nonce CSP frais ; la
que si elle est absente) dont le connect-src autorise les réponse pose sa propre CSP (préservée par
origines sœurs — sinon le navigateur bloquerait les appels SecurityHeadersMiddleware, qui ne la pose que si elle est
sœurs avant même CORS. La CSP par défaut (same-origin) reste absente) dont le connect-src autorise les origines sœurs ET
inchangée pour toutes les autres pages. l'origine de l'agent — sinon le navigateur bloquerait les
appels sœurs et l'appel console (onglet Console Spark,
GET /api/v1/recherche) avant même CORS. La CSP par défaut
(same-origin) reste inchangée pour toutes les autres pages.
""" """
import json as _json import json as _json
@@ -989,6 +992,7 @@ if FRONTEND_DIR.exists():
("{{URL_FLOWDECK}}", "flowdeck"), ("{{URL_FLOWDECK}}", "flowdeck"),
("{{URL_TIKTAK}}", "tiktak"), ("{{URL_TIKTAK}}", "tiktak"),
("{{URL_KANBANTE}}", "kanbante"), ("{{URL_KANBANTE}}", "kanbante"),
("{{URL_AGENT}}", "agent"),
): ):
html = html.replace(jeton, urls.get(cle, "")) html = html.replace(jeton, urls.get(cle, ""))
script = ( script = (
@@ -1003,7 +1007,8 @@ if FRONTEND_DIR.exists():
html = inject_csp_nonce(html, request.state.csp_nonce) html = inject_csp_nonce(html, request.state.csp_nonce)
soeurs = " ".join(sorted({ soeurs = " ".join(sorted({
origin_of(urls.get(cle, "")) or "" origin_of(urls.get(cle, "")) or ""
for cle in ("obsigate", "flowdeck", "tiktak", "kanbante") for cle in ("obsigate", "flowdeck", "tiktak", "kanbante",
"agent")
} - {""})) } - {""}))
csp = ( csp = (
"default-src 'self'; " "default-src 'self'; "
+128 -31
View File
@@ -46,23 +46,35 @@
* -> { groups: [{ full_name, rows: [{ issue, column }] }] } * -> { groups: [{ full_name, rows: [{ issue, column }] }] }
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre * Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
* adressable — même limite que la page /search native de Kanbante). * adressable — même limite que la page /search native de Kanbante).
* - Console Spark : AUCUN endpoint de recherche mergé au moment de * - Console Spark : GET {base-console}/api/v1/recherche?q=&limite=
* cette livraison (agent-commun : seule la PR #12 est ouverte, autre * (agent-commun, authentifié par le cookie SSO draco_session) ->
* programme — ce dépôt n'est pas touché ici). Le code tente quand * { q, domain, limite_par_type, resultats[], par_type,
* même l'appel conventionnel GET {base-console}/api/search?q= (même * non_cherchables[] } ; chaque résultat porte type, id, titre,
* convention que les autres hôtes ; chemin À CONFIRMER avec le * extrait, statut, domaine, section, objet et lien (relatif à
* travailleur console dès que son endpoint atterrit) et, en cas * l'origine console). q < 2 caractères -> 422. Types cherchables :
* d'échec, de HTTP non-OK ou de format non reconnu, affiche * discussion, mission, approbation, livrable, activite,
* honnêtement « Console Spark : recherche indisponible pour * planification, objectif, identite (aucun secret). Liens profonds
* l'instant » — jamais de faux résultats, jamais de faux 0. La base * (préfixés par la base console) : discussion
* console vient, dans l'ordre, de data-base-console rendu par l'hôte, * /?section=conversation&disc=<id>, mission
* de window.DRACO_URLS.agent (ObsiGate) / window.DRACO_AGENT_URL * /?section=missions&objet=<id>, approbation
* (TikTak), de la dérivation -dev si l'hôte courant est -dev, du * /?section=approbations&objet=<id>, objectif
* href d'un onglet de barre data-app="console"/"spark" s'il existe, * /?section=objectifs&objet=<id>, activite /?activite=<id> ;
* sinon du défaut prod agent.dracodev.net. L'onglet console n'est * livrable /?section=livrables, planification /?section=planifie
* interrogé que lorsqu'il est affiché (ou quand une recherche a lieu * et identite /?section=identites ouvrent la vue sans sélection
* pendant qu'il est affiché) : « Tout » ne le contient pas, pour ne * d'objet. L'onglet appelle avec limite=20 (plafond par type) car
* pas polluer la vue groupée d'un message d'indisponibilité. * il affiche la liste complète, comme les onglets d'applications.
* En cas d'échec, de HTTP non-OK ou de format non reconnu, la
* section affiche honnêtement « Console Spark : recherche
* indisponible pour l'instant » — jamais de faux résultats,
* jamais de faux 0. La base console vient, dans l'ordre, de
* data-base-console rendu par l'hôte, de window.DRACO_URLS.agent
* (ObsiGate) / window.DRACO_AGENT_URL (TikTak), de la dérivation
* -dev si l'hôte courant est -dev, du href d'un onglet de barre
* data-app="console"/"spark"/"agent" s'il existe, sinon du défaut
* prod agent.dracodev.net. L'onglet console n'est interrogé que
* lorsqu'il est affiché (ou quand une recherche a lieu pendant
* qu'il est affiché) : « Tout » ne le contient pas, pour ne pas
* polluer la vue groupée d'un message d'indisponibilité.
* *
* Résolution des bases sœurs (même ordre que la barre v0.1.6) : * Résolution des bases sœurs (même ordre que la barre v0.1.6) :
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite > * 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
@@ -76,12 +88,14 @@
* credentials:"include". Le navigateur envoie alors les cookies de * credentials:"include". Le navigateur envoie alors les cookies de
* chaque origine, dont le cookie SSO partagé draco_session * chaque origine, dont le cookie SSO partagé draco_session
* (Domain=.dracodev.net), accepté par les 4 API de recherche * (Domain=.dracodev.net), accepté par les 4 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68). * (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68)
* Chaque sœur n'autorise en CORS que les origines sœurs * et par GET /api/v1/recherche de la console (identity() accepte
* (GET sur l'endpoint de recherche uniquement, pas de "*"). * draco_session). Chaque sœur n'autorise en CORS que les origines
* sœurs (GET sur l'endpoint de recherche uniquement, pas de "*") ;
* côté console, un middleware borné à /api/v1/recherche autorise
* de même les 8 origines hôtes prod ET dev, avec credentials.
* Une sœur injoignable / non autorisée ne casse pas les autres : * Une sœur injoignable / non autorisée ne casse pas les autres :
* sa section affiche l'erreur + un lien vers sa recherche native. * sa section affiche l'erreur + un lien vers sa recherche native.
* Le correctif CORS lui-même est chez un autre travailleur.
*/ */
(function (global) { (function (global) {
"use strict"; "use strict";
@@ -104,7 +118,8 @@
}; };
var PROD_AGENT = "https://agent.dracodev.net/"; var PROD_AGENT = "https://agent.dracodev.net/";
var DEV_AGENT = "https://agent-dev.dracodev.net/"; var DEV_AGENT = "https://agent-dev.dracodev.net/";
var CONSOLE_SEARCH_PATH = "api/search"; var CONSOLE_SEARCH_PATH = "api/v1/recherche";
var CONSOLE_SEARCH_LIMIT = 20;
var APPS = [ var APPS = [
{ key: "obsigate", label: "ObsiGate" }, { key: "obsigate", label: "ObsiGate" },
{ key: "flowdeck", label: "Flowdeck" }, { key: "flowdeck", label: "Flowdeck" },
@@ -112,6 +127,24 @@
{ key: "kanbante", label: "Kanbante" }, { key: "kanbante", label: "Kanbante" },
]; ];
var CONSOLE_APP = { key: "console", label: "Console Spark" }; var CONSOLE_APP = { key: "console", label: "Console Spark" };
var CONSOLE_TYPE_LABELS = {
discussion: "Discussion",
mission: "Mission",
approbation: "Approbation",
livrable: "Livrable",
activite: "Activité",
planification: "Planification",
objectif: "Objectif",
identite: "Identité",
};
/* Lien profond console (relatif à l'origine console) quand le
* résultat n'en porte pas : même règle que _lien_recherche
* d'agent-commun (section + objet, activite en section=null). */
var CONSOLE_VIEW_LINKS = {
livrable: "/?section=livrables",
planification: "/?section=planifie",
identite: "/?section=identites",
};
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"]; var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
var COLUMN_LABELS = { var COLUMN_LABELS = {
backlog: "Backlog", backlog: "Backlog",
@@ -182,7 +215,8 @@
if (!u && isDevHost(opts.host)) u = DEV_AGENT; if (!u && isDevHost(opts.host)) u = DEV_AGENT;
if (!u) if (!u)
u = cleanUrl( u = cleanUrl(
opts.tabHrefs && (opts.tabHrefs.console || opts.tabHrefs.spark) opts.tabHrefs &&
(opts.tabHrefs.console || opts.tabHrefs.spark || opts.tabHrefs.agent)
); );
if (!u) u = PROD_AGENT; if (!u) u = PROD_AGENT;
return withSlash(u); return withSlash(u);
@@ -336,19 +370,71 @@
return []; return [];
} }
/* Normalisation console, défensive : accepte results/discussions/ /* Normalisation console : format réel GET /api/v1/recherche,
* items. Un élément sans URL exploitable (http(s), /relatif, #ancre) * { resultats: [{ type, id, titre, extrait, statut, domaine,
* garde url=null et sera rendu en <span>, pas en faux lien profond. * section, objet, lien }] }. Le lien relatif est préfixé par la
* Retourne { recognized, items } : recognized=false -> l'appelant * base console ; sans lien, il est reconstruit depuis type/id
* affiche « indisponible », jamais un vide mensonger. */ * (même règle que la console) ; sans lien reconstructible,
* url=null et le résultat est rendu en <span>, pas en faux lien.
* Les anciennes formes tolérées (results/discussions/items, avec
* url/href/link) restent acceptées en repli, pour ne pas casser
* un déploiement console antérieur. Retourne { recognized,
* items } : recognized=false -> l'appelant affiche
* « indisponible », jamais un vide mensonger. */
function consoleLinkFor(r) {
var lien = r.lien || "";
if (lien) return lien;
var id = r.id != null ? String(r.id) : "";
if (r.type === "discussion")
return "/?section=conversation&disc=" + encodeURIComponent(id);
if (r.type === "mission")
return "/?section=missions&objet=" + encodeURIComponent(id);
if (r.type === "approbation")
return "/?section=approbations&objet=" + encodeURIComponent(id);
if (r.type === "objectif")
return "/?section=objectifs&objet=" + encodeURIComponent(id);
if (r.type === "activite") return "/?activite=" + encodeURIComponent(id);
if (CONSOLE_VIEW_LINKS[r.type]) return CONSOLE_VIEW_LINKS[r.type];
if (r.section)
return (
"/?section=" +
encodeURIComponent(r.section) +
(r.objet != null
? "&objet=" + encodeURIComponent(String(r.objet))
: "")
);
return "";
}
function normalizeConsole(data, base) { function normalizeConsole(data, base) {
data = data || {}; data = data || {};
if (Array.isArray(data.resultats)) {
var items = data.resultats.map(function (r) {
r = r || {};
var rel = consoleLinkFor(r);
var url = null;
if (/^https?:\/\//.test(rel)) url = rel;
else if (rel.charAt(0) === "/" || rel.charAt(0) === "#")
url = base + String(rel).replace(/^\//, "");
var label = CONSOLE_TYPE_LABELS[r.type] || r.type || "Console";
var context = label;
if (r.statut) context += " · " + r.statut;
if (r.domaine) context += " · " + r.domaine;
return {
title: r.titre || r.title || "Sans titre",
snippet: snippetOf(r.extrait || r.snippet || ""),
context: context,
url: url,
};
});
return { recognized: true, items: items };
}
var list = null; var list = null;
if (Array.isArray(data.results)) list = data.results; if (Array.isArray(data.results)) list = data.results;
else if (Array.isArray(data.discussions)) list = data.discussions; else if (Array.isArray(data.discussions)) list = data.discussions;
else if (Array.isArray(data.items)) list = data.items; else if (Array.isArray(data.items)) list = data.items;
if (!list) return { recognized: false, items: [] }; if (!list) return { recognized: false, items: [] };
var items = list.map(function (r) { var legacy = list.map(function (r) {
r = r || {}; r = r || {};
var raw = r.url || r.href || r.link || ""; var raw = r.url || r.href || r.link || "";
var url = null; var url = null;
@@ -361,7 +447,7 @@
url: url, url: url,
}; };
}); });
return { recognized: true, items: items }; return { recognized: true, items: legacy };
} }
/* Recherche native de l'app (repli quand la section est en erreur). */ /* Recherche native de l'app (repli quand la section est en erreur). */
@@ -408,6 +494,9 @@
PROD_AGENT: PROD_AGENT, PROD_AGENT: PROD_AGENT,
DEV_AGENT: DEV_AGENT, DEV_AGENT: DEV_AGENT,
CONSOLE_SEARCH_PATH: CONSOLE_SEARCH_PATH, CONSOLE_SEARCH_PATH: CONSOLE_SEARCH_PATH,
CONSOLE_SEARCH_LIMIT: CONSOLE_SEARCH_LIMIT,
CONSOLE_TYPE_LABELS: CONSOLE_TYPE_LABELS,
consoleLinkFor: consoleLinkFor,
PER_APP_LIMIT: PER_APP_LIMIT, PER_APP_LIMIT: PER_APP_LIMIT,
FULL_LIMIT: FULL_LIMIT, FULL_LIMIT: FULL_LIMIT,
}; };
@@ -671,12 +760,20 @@
}; };
renderSection(sec, states.console, true); renderSection(sec, states.console, true);
renderTabs(); renderTabs();
var url = consoleBase + CONSOLE_SEARCH_PATH + "?q=" + encodeURIComponent(q); var url =
consoleBase +
CONSOLE_SEARCH_PATH +
"?q=" +
encodeURIComponent(q) +
"&limite=" +
CONSOLE_SEARCH_LIMIT;
fetchSection("console", url, function (err, data) { fetchSection("console", url, function (err, data) {
if (myRun !== runId) return; if (myRun !== runId) return;
if (err) { if (err) {
var msg; var msg;
if (err.name === "AbortError") msg = "délai dépassé (8 s)"; if (err.name === "AbortError") msg = "délai dépassé (8 s)";
else if (err.status === 422)
msg = "au moins 2 caractères requis";
else if (err.status) msg = "HTTP " + err.status; else if (err.status) msg = "HTTP " + err.status;
else msg = "injoignable (CORS ou réseau)"; else msg = "injoignable (CORS ou réseau)";
states.console = { states.console = {
+14 -7
View File
@@ -14,13 +14,15 @@
retomberait sur les défauts prod (les jetons sont ignorés par retomberait sur les défauts prod (les jetons sont ignorés par
le JS) — jamais d'URL inventée. le JS) — jamais d'URL inventée.
La barre ci-dessous est une copie statique du snippet figé de La barre ci-dessous est une copie statique du snippet figé de
index.html (commons v0.1.6) : les onglets sont des liens réels, index.html (commons v0.1.6 + chantier 5 fusion #69/#71, sans
le bouton central replace le curseur dans la boîte (recherche.js, bump) : les onglets sont des liens réels, le bouton central
clic data-act="palette"). Les menus, le panneau Spark, les replace le curseur dans la boîte (recherche.js,
alertes et le domaine restent des comportements de la SPA et ne clic data-act="palette"). Re-vendoring alignement recherche :
sont pas rejoués ici — l'autre travailleur (onglet Console Spark 5e onglet Console Spark (data-app="agent", jeton {{URL_AGENT}}
de la barre) devra reporter son ajout dans cette copie au remplacé par la route /recherche comme les 4 autres) et
re-vendoring, comme pour tout hôte. icône SVG du bouton Alertes, repris d'index.html. Les menus,
le panneau Spark, les alertes et le domaine restent des
comportements de la SPA et ne sont pas rejoués ici.
Sidebar statique (navigation de l'app) : les vaults, tags et Sidebar statique (navigation de l'app) : les vaults, tags et
récents dynamiques vivent dans la SPA, pas sur cette page. --> récents dynamiques vivent dans la SPA, pas sur cette page. -->
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
@@ -53,6 +55,10 @@
<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>
</a> </a>
<a class="draco-apptab" data-app="agent" href="{{URL_AGENT}}">
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
</a>
</nav> </nav>
</div> </div>
<div class="draco-bar-zone draco-bar-centre"> <div class="draco-bar-zone draco-bar-centre">
@@ -70,6 +76,7 @@
<button type="button" data-act="domaine" data-dom="perso">Perso</button> <button type="button" data-act="domaine" data-dom="perso">Perso</button>
</div> </div>
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées"> <button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="draco-alertes-txt">Alertes</span> <span class="draco-alertes-txt">Alertes</span>
<span class="draco-badge" data-badge="alertes" hidden>0</span> <span class="draco-badge" data-badge="alertes" hidden>0</span>
</button> </button>
+35
View File
@@ -141,4 +141,39 @@ ok(rp.resolveConsoleBase({ host: "tiktak-dev.dracodev.net" }) === "https://agent
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { console: "https://console-perso.example/" } }) === "https://console-perso.example/", "console : href onglet barre"); ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { console: "https://console-perso.example/" } }) === "https://console-perso.example/", "console : href onglet barre");
ok(rp.resolveConsoleBase({}) === "https://agent.dracodev.net/", "console : défaut prod"); ok(rp.resolveConsoleBase({}) === "https://agent.dracodev.net/", "console : défaut prod");
/* ---- Alignement console (agent-commun /api/v1/recherche) ---- */
let rc;
ok(rp.CONSOLE_SEARCH_PATH === "api/v1/recherche", "console : chemin reel /api/v1/recherche");
ok(rp.CONSOLE_SEARCH_LIMIT === 20, "console : limite 20 (plafond par type)");
rc = rp.normalizeConsole({ resultats: [
{ type: "discussion", id: "c42", titre: "Conv", extrait: "ex", statut: null, domaine: "pro", section: "conversation", objet: "c42", lien: "/?section=conversation&disc=c42" },
{ type: "mission", id: "t1", titre: "Mission", extrait: "ex", statut: "paused_budget", domaine: "pro", section: "missions", objet: "t1", lien: "/?section=missions&objet=t1" },
{ type: "approbation", id: "a9", titre: "publier", extrait: "ex", statut: "pending", domaine: null, section: "approbations", objet: "a9", lien: "/?section=approbations&objet=a9" },
{ type: "objectif", id: "o3", titre: "Obj", extrait: "ex", statut: "actif", domaine: "pro", section: "objectifs", objet: "o3", lien: "/?section=objectifs&objet=o3" },
{ type: "activite", id: "a7", titre: "Act", extrait: "ex", statut: "terminee", domaine: "pro", section: null, objet: "a7", lien: "/?activite=a7" },
{ type: "livrable", id: "l1", titre: "Liv", extrait: "ex", statut: "terminee", domaine: "pro", section: "livrables", objet: null, lien: "/?section=livrables" },
{ type: "planification", id: "p1", titre: "Plan", extrait: "ex", statut: "active", domaine: "pro", section: "planifie", objet: null, lien: "/?section=planifie" },
{ type: "identite", id: "i1", titre: "Id", extrait: "ex", statut: "active", domaine: null, section: "identites", objet: null, lien: "/?section=identites" },
] }, "https://agent-dev.dracodev.net/");
ok(rc.recognized === true && rc.items.length === 8, "console : 8 resultats reconnus");
ok(rc.items[0].url === "https://agent-dev.dracodev.net/?section=conversation&disc=c42", "console : lien profond discussion");
ok(rc.items[1].url === "https://agent-dev.dracodev.net/?section=missions&objet=t1" && rc.items[1].context === "Mission \u00b7 paused_budget \u00b7 pro", "console : lien mission + contexte statut/domaine");
ok(rc.items[2].url === "https://agent-dev.dracodev.net/?section=approbations&objet=a9", "console : lien profond approbation");
ok(rc.items[3].url === "https://agent-dev.dracodev.net/?section=objectifs&objet=o3", "console : lien profond objectif");
ok(rc.items[4].url === "https://agent-dev.dracodev.net/?activite=a7", "console : lien activite sans section");
ok(rc.items[5].url === "https://agent-dev.dracodev.net/?section=livrables", "console : livrable = vue seulement");
ok(rc.items[6].url === "https://agent-dev.dracodev.net/?section=planifie", "console : planification = vue seulement");
ok(rc.items[7].url === "https://agent-dev.dracodev.net/?section=identites", "console : identite = vue seulement");
rc = rp.normalizeConsole({ resultats: [
{ type: "mission", id: "t9", titre: "Sans lien", extrait: "", statut: null, domaine: null, section: "missions", objet: "t9" },
{ type: "activite", id: "a9", titre: "Act sans lien", extrait: "", statut: null, domaine: null, section: null, objet: "a9" },
] }, "https://agent.dracodev.net/");
ok(rc.items[0].url === "https://agent.dracodev.net/?section=missions&objet=t9", "console : lien reconstruit sans lien servi");
ok(rc.items[1].url === "https://agent.dracodev.net/?activite=a9", "console : lien activite reconstruit");
rc = rp.normalizeConsole({ resultats: [] }, "https://agent.dracodev.net/");
ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide reconnu (vide honnete, pas indisponible)");
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
console.log(`recherche.test.mjs : ${n} assertions OK`); console.log(`recherche.test.mjs : ${n} assertions OK`);