From 9772ecdcf609c87aea88df98a6f7c9318187c580 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:07:31 -0400 Subject: [PATCH 01/11] =?UTF-8?q?Recherche=20partout=20(#68)=20:=20page=20?= =?UTF-8?q?de=20r=C3=A9sultats=20unifi=C3=A9e=20=E2=80=94=20JS=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- static/js/recherche.js | 504 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 504 insertions(+) create mode 100644 static/js/recherche.js diff --git a/static/js/recherche.js b/static/js/recherche.js new file mode 100644 index 0000000..77aec9f --- /dev/null +++ b/static/js/recherche.js @@ -0,0 +1,504 @@ +/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68). + * + * Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque + * hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient + * des attributs data-* de #recherche, rendus côté serveur par l'hôte. + * + * Contrat d'API (audité, rien d'inventé) : + * - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8 + * -> { results: [{ vault, path, title, snippet }] } + * Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile). + * - Flowdeck : GET {base}/api/search?q= + * -> { pages: [{ title, excerpt, subtitle, url: "/pages/" }], + * collections: [{ title, subtitle, url: "/db/" }] } + * - TikTak : GET {base}/api/search?q= + * -> { tasks: [{ id, title, description, status, due_at }], ... } + * Lien profond vérifié : {base}search?q=&task= (page /search). + * - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison) + * -> { groups: [{ full_name, rows: [{ issue, column }] }] } + * Lien profond : {base}board/ (la carte n'a pas d'URL propre + * adressable — même limite que la page /search native de Kanbante). + * + * 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 > + * hôte de la requête > dérivation > défaut officiel) ; + * 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ; + * 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ; + * 4. href des onglets .draco-apptab rendus ; + * 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev). + * + * Authentification cross-origin : les appels sœurs partent avec + * credentials:"include". Le navigateur envoie alors les cookies de + * chaque origine, dont le cookie SSO partagé draco_session + * (Domain=.dracodev.net), accepté par les 4 API de recherche + * (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette + * livraison). Chaque sœur n'autorise en CORS que les origines sœurs + * (GET sur l'endpoint de recherche uniquement, pas de "*"). + * Une sœur injoignable / non autorisée ne casse pas les autres : + * sa section affiche l'erreur + un lien vers sa recherche native. + */ +(function (global) { + "use strict"; + + var PROD = { + obsigate: "https://og.dracodev.net/", + flowdeck: "https://flowdeck.dracodev.net/", + tiktak: "https://tiktak.dracodev.net/", + kanbante: "https://kanbante.dracodev.net/", + }; + var DEV = { + obsigate: "https://og-dev.dracodev.net/", + flowdeck: "https://flowdeck-dev.dracodev.net/", + tiktak: "https://tiktak-dev.dracodev.net/", + kanbante: "https://kanbante-dev.dracodev.net/", + }; + var APPS = [ + { key: "obsigate", label: "ObsiGate" }, + { key: "flowdeck", label: "Flowdeck" }, + { key: "tiktak", label: "TikTak" }, + { key: "kanbante", label: "Kanbante" }, + ]; + var COLUMN_LABELS = { + backlog: "Backlog", + in_progress: "En cours", + qa: "QA", + review: "Relecture", + todo: "À faire", + done: "Terminé", + }; + var TIMEOUT_MS = 8000; + var DEBOUNCE_MS = 250; + var PER_APP_LIMIT = 8; + + function esc(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function cleanUrl(u) { + if (!u) return null; + u = String(u).trim(); + if (!u || u.indexOf("{{") !== -1) return null; + return u; + } + + function withSlash(u) { + return u.charAt(u.length - 1) === "/" ? u : u + "/"; + } + + /* Même règle que la barre v0.1.6 : première étiquette en -dev ET + * domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */ + function isDevHost(hostname) { + if (!hostname) return false; + var h = String(hostname).toLowerCase(); + if (h.slice(-13) !== ".dracodev.net") return false; + var first = h.split(".")[0]; + return first.length > 4 && first.slice(-4) === "-dev"; + } + + /* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */ + function resolveBases(opts) { + opts = opts || {}; + var dev = isDevHost(opts.host); + var out = {}; + APPS.forEach(function (app) { + var key = app.key; + var u = cleanUrl(opts.explicit && opts.explicit[key]); + if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]); + if (!u && dev) u = DEV[key]; + if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]); + if (!u) u = PROD[key]; + out[key] = withSlash(u); + }); + return out; + } + + function searchPath(app, q) { + if (app === "obsigate") { + return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT; + } + return "api/search?q=" + encodeURIComponent(q); + } + + function snippetOf(s, n) { + s = String(s == null ? "" : s).replace(/\s+/g, " ").trim(); + return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s; + } + + /* Normalise la réponse d'une app en [{ title, snippet, context, url }]. + * base : base résolue de l'app (avec / final). q : requête (liens TikTak). */ + function normalize(app, data, base, q) { + data = data || {}; + if (app === "obsigate") { + return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) { + return { + title: r.title || r.path || "Sans titre", + snippet: snippetOf(r.snippet || ""), + context: (r.vault || "") + (r.path ? " · " + r.path : ""), + url: + base + + "#file=" + + encodeURIComponent(r.vault || "") + + ":" + + encodeURIComponent(r.path || ""), + }; + }); + } + if (app === "flowdeck") { + var items = (data.pages || []).concat(data.collections || []); + return items.slice(0, PER_APP_LIMIT).map(function (r) { + var rel = String(r.url || "").replace(/^\//, ""); + return { + title: r.title || "Sans titre", + snippet: snippetOf(r.excerpt || ""), + context: r.subtitle || (r.type === "collection" ? "Base" : "Page"), + url: base + rel, + }; + }); + } + if (app === "tiktak") { + return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) { + var ctx = t.status === "done" ? "Terminée" : "Active"; + if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10); + return { + title: t.title || "Sans titre", + snippet: snippetOf(t.description || ""), + context: "Tâche · " + ctx, + url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id, + }; + }); + } + if (app === "kanbante") { + var rows = []; + (data.groups || []).forEach(function (g) { + (g.rows || []).forEach(function (row) { + rows.push({ group: g.full_name, row: row }); + }); + }); + return rows.slice(0, PER_APP_LIMIT).map(function (x) { + var issue = x.row.issue || {}; + var col = COLUMN_LABELS[x.row.column] || x.row.column || ""; + return { + title: "#" + issue.number + " " + (issue.title || "Sans titre"), + snippet: snippetOf(issue.body || ""), + context: x.group + (col ? " · " + col : ""), + url: base + "board/" + x.group, + }; + }); + } + return []; + } + + /* Recherche native de l'app (repli quand la section est en erreur). */ + function nativeSearchUrl(app, base, q) { + if (app === "tiktak" || app === "kanbante") { + return base + "search?q=" + encodeURIComponent(q || ""); + } + /* ObsiGate et Flowdeck n'ont pas de page de recherche adressable : + * leur recherche native vit dans la palette (Ctrl+K) de l'app. */ + return base; + } + + function nativeSearchLabel(app) { + if (app === "tiktak" || app === "kanbante") { + return "Ouvrir la recherche dans " + appLabel(app); + } + return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)"; + } + + function appLabel(key) { + for (var i = 0; i < APPS.length; i++) { + if (APPS[i].key === key) return APPS[i].label; + } + return key; + } + + var api = { + esc: esc, + isDevHost: isDevHost, + resolveBases: resolveBases, + searchPath: searchPath, + normalize: normalize, + nativeSearchUrl: nativeSearchUrl, + PROD: PROD, + DEV: DEV, + }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + global.RecherchePartout = api; + + /* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */ + if (typeof document === "undefined") return; + + function fetchSection(app, url, onDone) { + var ctrl = null; + var timer = null; + try { + ctrl = new AbortController(); + timer = setTimeout(function () { + ctrl.abort(); + }, TIMEOUT_MS); + } catch (e) { + ctrl = null; + } + var opts = { headers: { Accept: "application/json" }, credentials: "include" }; + if (ctrl) opts.signal = ctrl.signal; + fetch(url, opts) + .then(function (resp) { + if (!resp.ok) { + var err = new Error("HTTP " + resp.status); + err.status = resp.status; + throw err; + } + return resp.json(); + }) + .then(function (data) { + if (timer) clearTimeout(timer); + onDone(null, data); + }) + .catch(function (err) { + if (timer) clearTimeout(timer); + onDone(err, null); + }); + } + + function renderSection(section, state) { + /* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */ + var body = section.querySelector(".rp-body"); + var count = section.querySelector(".rp-count"); + var toutVoir = section.querySelector(".rp-all"); + toutVoir.href = nativeSearchUrl(state.app, state.base, state.q); + toutVoir.textContent = "Tout voir"; + toutVoir.title = nativeSearchLabel(state.app); + if (state.status === "loading") { + count.textContent = ""; + body.innerHTML = '

Recherche…

'; + return; + } + if (state.status === "error") { + count.textContent = ""; + body.innerHTML = + '

' + + esc(state.message) + + ' — ' + + esc(nativeSearchLabel(state.app)) + + "

"; + return; + } + if (state.status === "empty") { + count.textContent = ""; + body.innerHTML = + '

Aucun résultat pour « ' + esc(state.q) + " ».

"; + return; + } + count.textContent = String(state.items.length); + body.innerHTML = state.items + .map(function (item) { + return ( + '' + + '' + + esc(item.title) + + "" + + (item.context + ? '' + esc(item.context) + "" + : "") + + (item.snippet + ? '' + esc(item.snippet) + "" + : "") + + "" + ); + }) + .join(""); + } + + function init() { + var root = document.getElementById("recherche"); + if (!root) return; + var input = document.getElementById("rp-q"); + if (!input) return; + var hostApp = root.getAttribute("data-host") || ""; + var explicit = {}; + APPS.forEach(function (app) { + var v = root.getAttribute("data-base-" + app.key); + if (v) explicit[app.key] = v; + }); + var barUrls = {}; + try { + if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls; + } catch (e) {} + var tabHrefs = {}; + try { + document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) { + var href = tab.getAttribute("href"); + if (href) tabHrefs[tab.getAttribute("data-app")] = href; + }); + } catch (e) {} + var bases = resolveBases({ + host: global.location.hostname, + explicit: explicit, + barUrls: barUrls, + tabHrefs: tabHrefs, + }); + var sections = {}; + APPS.forEach(function (app) { + var el = document.querySelector('[data-rp-section="' + app.key + '"]'); + if (el) sections[app.key] = el; + }); + + var currentQ = ""; + var runId = 0; + var debounceTimer = null; + + function runSearch(q) { + currentQ = q; + var myRun = ++runId; + if (!q) { + APPS.forEach(function (app) { + var sec = sections[app.key]; + if (!sec) return; + sec.querySelector(".rp-body").innerHTML = + '

Tapez une recherche ci-dessus.

'; + sec.querySelector(".rp-count").textContent = ""; + sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], ""); + }); + return; + } + APPS.forEach(function (app) { + var sec = sections[app.key]; + if (!sec) return; + renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q }); + var url; + if (app.key === hostApp) { + /* Même origine : pas de CORS, pas de CSP cross-origin. */ + url = "/" + searchPath(app.key, q); + } else { + url = bases[app.key] + searchPath(app.key, q); + } + fetchSection(app.key, url, function (err, data) { + if (myRun !== runId) return; + if (err) { + var msg; + if (err.name === "AbortError") msg = "Délai dépassé (8 s)"; + else if (err.status === 401 || err.status === 403) + msg = "Connexion requise sur " + app.label; + else if (err.status) msg = "Indisponible (HTTP " + err.status + ")"; + else msg = "Injoignable depuis cet hôte (CORS ou réseau)"; + renderSection(sec, { + status: "error", + message: msg, + app: app.key, + base: bases[app.key], + q: q, + }); + return; + } + var items = normalize(app.key, data, bases[app.key], q); + renderSection(sec, { + status: items.length ? "ok" : "empty", + items: items, + app: app.key, + base: bases[app.key], + q: q, + }); + }); + }); + } + + function scheduleSearch(q, immediate) { + if (debounceTimer) clearTimeout(debounceTimer); + if (immediate) { + runSearch(q); + return; + } + debounceTimer = setTimeout(function () { + runSearch(q); + }, DEBOUNCE_MS); + } + + function syncUrl(q) { + try { + var url = new URL(global.location.href); + if (q) url.searchParams.set("q", q); + else url.searchParams.delete("q"); + global.history.replaceState(null, "", url.toString()); + } catch (e) {} + } + + input.addEventListener("input", function () { + var q = input.value.trim(); + syncUrl(q); + scheduleSearch(q, false); + }); + input.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + var first = document.querySelector("[data-rp-link]"); + if (first) { + e.preventDefault(); + first.click(); + } + } else if (e.key === "Escape") { + input.value = ""; + syncUrl(""); + scheduleSearch("", true); + input.focus(); + } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { + moveFocus(e.key === "ArrowDown" ? 1 : -1, e); + } + }); + document.addEventListener( + "keydown", + function (e) { + var target = e.target; + var typing = + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable); + if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) { + e.preventDefault(); + e.stopPropagation(); + input.focus(); + input.select(); + return; + } + if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) { + moveFocus(e.key === "ArrowDown" ? 1 : -1, e); + } + }, + true + ); + + function moveFocus(delta, e) { + var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]")); + if (!links.length) return; + var active = document.activeElement; + var idx = links.indexOf(active); + var next; + if (idx === -1) next = delta > 0 ? 0 : links.length - 1; + else next = (idx + delta + links.length) % links.length; + e.preventDefault(); + links[next].focus(); + } + + var params = new URLSearchParams(global.location.search); + var initialQ = (params.get("q") || "").trim(); + if (initialQ) { + input.value = initialQ; + runSearch(initialQ); + } else { + runSearch(""); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(typeof window !== "undefined" ? window : this); From ecf7f09fc5aba1873fc5af5622bcefab168a5fc6 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:07:35 -0400 Subject: [PATCH 02/11] Recherche partout (#68) : styles de la page --- static/css/recherche.css | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 static/css/recherche.css diff --git a/static/css/recherche.css b/static/css/recherche.css new file mode 100644 index 0000000..f8ab9d9 --- /dev/null +++ b/static/css/recherche.css @@ -0,0 +1,35 @@ +/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter + * toute collision avec les styles de l'hôte. Couleurs neutres héritées + * autant que possible : la page reste lisible en thème clair et sombre. */ +.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; } +.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; } +.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; } +.rp-form { display: flex; gap: 8px; margin-bottom: 24px; } +.rp-input { + flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px; + border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit; +} +.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; } +.rp-section { margin-bottom: 28px; } +.rp-head { + display: flex; align-items: baseline; gap: 10px; + border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px; +} +.rp-head h2 { font-size: 1.05rem; margin: 0; } +.rp-count { + font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff; + border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center; +} +.rp-count:empty { display: none; } +.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; } +.rp-state { opacity: .75; margin: 8px 0; } +.rp-error { opacity: 1; } +.rp-item { + display: block; padding: 8px 10px; border-radius: 8px; + text-decoration: none; color: inherit; +} +.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; } +.rp-item:focus-visible { outline: 2px solid #3b82f6; } +.rp-title { display: block; font-weight: 600; } +.rp-context { display: block; font-size: .8rem; opacity: .65; } +.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; } From 61f299305ac2db9bddb80c532680f0fa8cc91506 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:07:39 -0400 Subject: [PATCH 03/11] Recherche partout (#68) : gabarit de la page --- app/templates/recherche.html | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 app/templates/recherche.html diff --git a/app/templates/recherche.html b/app/templates/recherche.html new file mode 100644 index 0000000..7a0f806 --- /dev/null +++ b/app/templates/recherche.html @@ -0,0 +1,48 @@ + + + + + + Rechercher partout — Kanbante + + + + + + {% include "_header.html" %} + +
+

Rechercher partout

+

Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.

+
+ +
+
+

ObsiGate — notes

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+

Flowdeck — pages

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+

TikTak — tâches

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+

Kanbante — cartes

Tout voir
+

Tapez une recherche ci-dessus.

+
+
+ +
+ Kanbante v{{ version }} · recherche partout +
+ + + From a30fa9d8f3b0f22ac810a593916a6b387b2a2576 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:07:41 -0400 Subject: [PATCH 04/11] =?UTF-8?q?Recherche=20partout=20(#68)=20:=20JSON=20?= =?UTF-8?q?de=20recherche=20+=20CORS=20born=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/recherche.py | 204 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 app/recherche.py diff --git a/app/recherche.py b/app/recherche.py new file mode 100644 index 0000000..e3ff103 --- /dev/null +++ b/app/recherche.py @@ -0,0 +1,204 @@ +"""Recherche fédérée (fusion-trois-apps #68) : JSON de recherche, +CORS bornée et conversion des groupes d'issues. + +Ce module ne dépend d'aucun autre module applicatif à l'import (les +résolveurs sont appelés paresseusement) : les fonctions pures sont +testables sans base ni serveur. + +- ``GET /api/search`` (route dans ``app/main.py``) réutilise la + recherche Gitea de la page ``/search`` (titres + corps, tous états, + tous projets du workspace) et la regroupe par dépôt. +- La CORS bornée n'autorise que ``GET /api/search``, et seulement + pour les origines sœurs résolues par ``app.config`` (explicite > + hôte de la requête > dérivation > défaut officiel — donc ``-dev`` + reste ``-dev``), jamais ``*``. Le middleware est un ASGI pur : + il répond lui-même aux preflights OPTIONS (qui ne portent jamais + de cookies, donc qu'``AuthMiddleware`` refuserait sinon) et ajoute + les en-têtes aux réponses de recherche, sans toucher aux autres + routes. +""" + +from __future__ import annotations + +from urllib.parse import urlparse + +SEARCH_PATH = "/api/search" +_SNIPPET_LEN = 160 + +PROD_ORIGINS = frozenset({ + "https://og.dracodev.net", + "https://flowdeck.dracodev.net", + "https://tiktak.dracodev.net", + "https://kanbante.dracodev.net", +}) + +DEV_ORIGINS = frozenset({ + "https://og-dev.dracodev.net", + "https://flowdeck-dev.dracodev.net", + "https://tiktak-dev.dracodev.net", + "https://kanbante-dev.dracodev.net", +}) + +# Clés du dictionnaire rendu par render() (app/main.py). +_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante") + + +def is_dev_host(hostname: str | None) -> bool: + """Même règle que la barre v0.1.6 (voir static/js/recherche.js).""" + if not hostname: + return False + h = hostname.lower() + if not h.endswith(".dracodev.net"): + return False + first = h.split(".")[0] + return len(first) > 4 and first.endswith("-dev") + + +def origin_of(url: str | None) -> str | None: + """Origine (schéma + hôte) d'une URL, sans chemin ni ``/`` final.""" + if not url: + return None + parsed = urlparse(url.strip()) + if parsed.scheme not in ("http", "https") or not parsed.hostname: + return None + host = parsed.hostname.lower() + port = f":{parsed.port}" if parsed.port else "" + return f"{parsed.scheme}://{host}{port}" + + +def sister_origins(resolved: dict | None = None) -> frozenset: + """Origines autorisées pour la lecture cross-origin de /api/search. + + ``resolved`` : dictionnaire ``draco_urls`` rendu par ``render()``. + Sans argument, le résolveur est appelé (import paresseux) ; en cas + d'échec, repli sur prod + dev officiels (premier parti uniquement). + """ + if resolved is None: + try: + from app.config import get_settings, resoudre_urls_draco + + urls = resoudre_urls_draco(base=get_settings()) + resolved = { + "obsigate": urls.sso_url, + "flowdeck": urls.flowdeck_url, + "tiktak": urls.tiktak_url, + "kanbante": urls.public_url, + } + except Exception: + return PROD_ORIGINS | DEV_ORIGINS + origins = set() + for key in _SOEUR_KEYS: + origin = origin_of((resolved or {}).get(key)) + if origin: + origins.add(origin) + return frozenset(origins) + + +def cors_headers_for(origin: str | None, resolved: dict | None = None) -> dict | None: + """En-têtes CORS d'une réponse de recherche, ou None si refusée.""" + if not origin: + return None + if origin.strip() not in sister_origins(resolved): + return None + return { + "Access-Control-Allow-Origin": origin.strip(), + "Access-Control-Allow-Credentials": "true", + "Vary": "Origin", + } + + +def preflight_headers(origin: str | None, resolved: dict | None = None) -> dict | None: + """En-têtes d'une réponse de preflight OPTIONS pour /api/search.""" + headers = cors_headers_for(origin, resolved) + if headers is None: + return None + headers.update({ + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Accept, Content-Type, Authorization", + "Access-Control-Max-Age": "600", + }) + return headers + + +def groupes_vers_json(groups, limit: int = 20): + """Convertit la sortie de ``_group_issue_results`` en JSON public. + + Seuls les champs utiles sont exposés (numéro, titre, état, corps + tronqué à 160 caractères, labels, colonne). Retourne + ``(groupes, total)`` où ``total`` est le nombre de lignes + retournées (plafonné à ``limit``). + """ + out: list[dict] = [] + total = 0 + restant = max(1, min(int(limit), 50)) + for group in groups or []: + rows: list[dict] = [] + for row in (group or {}).get("rows", []) or []: + if restant <= 0: + break + issue = (row or {}).get("issue", {}) or {} + labels = [] + for label in issue.get("labels") or []: + if isinstance(label, dict): + labels.append({"name": label.get("name"), "color": label.get("color")}) + rows.append({ + "issue": { + "number": issue.get("number"), + "title": issue.get("title"), + "state": issue.get("state"), + "body": (issue.get("body") or "")[:_SNIPPET_LEN], + "labels": labels, + }, + "column": (row or {}).get("column"), + }) + restant -= 1 + total += 1 + if rows: + out.append({"full_name": (group or {}).get("full_name"), "rows": rows}) + if restant <= 0: + break + return out, total + + +class RechercheCorsMiddleware: + """ASGI pur : CORS bornée pour ``GET /api/search`` (voir module).""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope.get("type") != "http": + await self.app(scope, receive, send) + return + path = scope.get("path", "") + headers_in = { + k.decode("latin-1").lower(): v.decode("latin-1") + for k, v in scope.get("headers", []) + } + origin = headers_in.get("origin", "") + if path == SEARCH_PATH and scope.get("method") == "OPTIONS": + preflight = preflight_headers(origin) + if preflight is not None: + body = b"" + headers_out = [(k.lower().encode("latin-1"), v.encode("latin-1")) + for k, v in preflight.items()] + headers_out.append((b"content-length", b"0")) + await send({"type": "http.response.start", "status": 204, + "headers": headers_out}) + await send({"type": "http.response.body", "body": body}) + return + if path != SEARCH_PATH: + await self.app(scope, receive, send) + return + + async def send_with_cors(message): + if message["type"] == "http.response.start": + cors = cors_headers_for(origin) + if cors is not None: + message["headers"] = list(message.get("headers", [])) + [ + (k.lower().encode("latin-1"), v.encode("latin-1")) + for k, v in cors.items() + ] + await send(message) + + await self.app(scope, receive, send_with_cors) From e5282079b835c1f4349dfc0cbcef94bf904d9221 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:07:45 -0400 Subject: [PATCH 05/11] Recherche partout (#68) : tests module recherche --- tests/test_recherche.py | 85 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/test_recherche.py diff --git a/tests/test_recherche.py b/tests/test_recherche.py new file mode 100644 index 0000000..b11cfb8 --- /dev/null +++ b/tests/test_recherche.py @@ -0,0 +1,85 @@ +"""Tests de la recherche fédérée Kanbante (fusion #68). + +Fonctions pures de app/recherche.py uniquement : aucune base, aucun +serveur, aucune forge. +""" + +from app.recherche import ( + cors_headers_for, + groupes_vers_json, + is_dev_host, + origin_of, + preflight_headers, + sister_origins, +) + +RESOLVED_DEV = { + "obsigate": "https://og-dev.dracodev.net/", + "flowdeck": "https://flowdeck-dev.dracodev.net", + "tiktak": "https://tiktak-dev.dracodev.net/", + "kanbante": "https://kanbante-dev.dracodev.net/", +} + + +def test_is_dev_host(): + assert is_dev_host("kanbante-dev.dracodev.net") is True + assert is_dev_host("kanbante.dracodev.net") is False + assert is_dev_host("x-dev.dracodev.net.evil") is False + assert is_dev_host(None) is False + + +def test_origin_of(): + assert origin_of("https://og-dev.dracodev.net/") == "https://og-dev.dracodev.net" + assert origin_of("{{URL_OBSIGATE}}") is None + + +def test_sister_origins_dev(): + assert sister_origins(RESOLVED_DEV) == { + "https://og-dev.dracodev.net", + "https://flowdeck-dev.dracodev.net", + "https://tiktak-dev.dracodev.net", + "https://kanbante-dev.dracodev.net", + } + + +def test_cors_headers(): + headers = cors_headers_for("https://tiktak-dev.dracodev.net", RESOLVED_DEV) + assert headers["Access-Control-Allow-Origin"] == "https://tiktak-dev.dracodev.net" + assert headers["Access-Control-Allow-Credentials"] == "true" + assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None + assert cors_headers_for("https://og.dracodev.net", RESOLVED_DEV) is None + + +def test_preflight_headers(): + headers = preflight_headers("https://og-dev.dracodev.net", RESOLVED_DEV) + assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS" + assert preflight_headers("https://evil.example", RESOLVED_DEV) is None + + +def test_groupes_vers_json_tronque_et_filtre(): + groups = [{ + "full_name": "Projets/site", + "rows": [ + {"issue": {"number": 12, "title": "Bug", "state": "open", + "body": "x" * 500, "labels": [{"name": "bug", "color": "ff0000"}], + "token_secret": "ne-doit-pas-sortir"}, + "column": "in_progress"}, + {"issue": {"number": 13, "title": "Autre", "state": "closed", + "body": "", "labels": []}, + "column": "done"}, + ], + }] + data, total = groupes_vers_json(groups, limit=1) + assert total == 1 + assert len(data[0]["rows"]) == 1 + row = data[0]["rows"][0] + assert row["issue"]["number"] == 12 + assert len(row["issue"]["body"]) == 160 + assert "token_secret" not in row["issue"] + assert row["issue"]["labels"] == [{"name": "bug", "color": "ff0000"}] + assert row["column"] == "in_progress" + + +def test_groupes_vers_json_vide(): + assert groupes_vers_json([], limit=20) == ([], 0) + assert groupes_vers_json(None, limit=20) == ([], 0) From 903616a8cdcce774fe826cb5e49f6011a56fb932 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:07:59 -0400 Subject: [PATCH 06/11] Recherche partout (#68) : tests Node du JS --- tests/recherche.test.js | 68 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/recherche.test.js diff --git a/tests/recherche.test.js b/tests/recherche.test.js new file mode 100644 index 0000000..c1c3c02 --- /dev/null +++ b/tests/recherche.test.js @@ -0,0 +1,68 @@ +/* Tests Node sans navigateur de recherche.js (logique pure). + * Usage : node recherche.test.js — sortie 0 si tout passe. */ +"use strict"; +const assert = require("node:assert/strict"); +const rp = require("../static/js/recherche.js"); + +let n = 0; +function ok(cond, label) { + assert.ok(cond, label); + n += 1; +} + +/* esc */ +ok(rp.esc('&') === "<a href="x">&", "esc échappe HTML"); + +/* isDevHost : même règle que la barre v0.1.6 */ +ok(rp.isDevHost("tiktak-dev.dracodev.net") === true, "dev reconnu"); +ok(rp.isDevHost("tiktak.dracodev.net") === false, "prod non dev"); +ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté"); +ok(rp.isDevHost("dev.dracodev.net") === false, "étiquette trop courte rejetée"); +ok(rp.isDevHost("TIKTAK-DEV.DRACODEV.NET") === true, "insensible à la casse"); + +/* resolveBases : ordre explicite > barre > dev > href > prod */ +let b = rp.resolveBases({ + host: "tiktak-dev.dracodev.net", + explicit: { obsigate: "https://og-perso.example/" }, + barUrls: { flowdeck: "https://flowdeck-barre.example/" }, + tabHrefs: { kanbante: "https://kanbante-href.example/" }, +}); +ok(b.obsigate === "https://og-perso.example/", "explicite prioritaire"); +ok(b.flowdeck === "https://flowdeck-barre.example/", "barre avant dev"); +ok(b.tiktak === "https://tiktak-dev.dracodev.net/", "dev pour l'hôte courant"); +ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href"); +b = rp.resolveBases({ host: "tiktak.dracodev.net", tabHrefs: { kanbante: "https://kanbante-href.example/" } }); +ok(b.kanbante === "https://kanbante-href.example/", "href en prod"); +ok(b.obsigate === "https://og.dracodev.net/", "défaut prod"); +b = rp.resolveBases({}); +ok(b.flowdeck === "https://flowdeck.dracodev.net/", "défaut prod sans hôte"); + +/* searchPath */ +ok(rp.searchPath("obsigate", "café & thé") === "api/search?q=caf%C3%A9%20%26%20th%C3%A9&vault=all&limit=8", "path ObsiGate"); +ok(rp.searchPath("tiktak", "a b") === "api/search?q=a%20b", "path TikTak"); + +/* normalize ObsiGate */ +let items = rp.normalize("obsigate", { results: [{ vault: "home-bruno", path: "Notes/Idée.md", title: "Idée", snippet: "extrait" }] }, "https://og-dev.dracodev.net/", "idée"); +ok(items.length === 1 && items[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md", "lien profond ObsiGate"); +ok(items[0].context === "home-bruno · Notes/Idée.md", "contexte ObsiGate"); + +/* normalize Flowdeck */ +items = rp.normalize("flowdeck", { pages: [{ title: "P", excerpt: "ex", subtitle: "Espace", url: "/pages/42" }], collections: [{ title: "B", subtitle: "Database · Espace", url: "/db/7" }] }, "https://flowdeck-dev.dracodev.net/", "p"); +ok(items.length === 2 && items[0].url === "https://flowdeck-dev.dracodev.net/pages/42", "lien page Flowdeck"); +ok(items[1].url === "https://flowdeck-dev.dracodev.net/db/7", "lien base Flowdeck"); + +/* normalize TikTak */ +items = rp.normalize("tiktak", { tasks: [{ id: 9, title: "Faire X", description: "d", status: "active", due_at: "2026-10-30 09:00:00" }] }, "https://tiktak-dev.dracodev.net/", "faire"); +ok(items[0].url === "https://tiktak-dev.dracodev.net/search?q=faire&task=9", "lien profond TikTak"); +ok(items[0].context === "Tâche · Active · échéance 2026-10-30", "contexte TikTak"); + +/* normalize Kanbante */ +items = rp.normalize("kanbante", { groups: [{ full_name: "Projets/site", rows: [{ issue: { number: 12, title: "Bug", body: "corps" }, column: "in_progress" }] }] }, "https://kanbante-dev.dracodev.net/", "bug"); +ok(items[0].title === "#12 Bug" && items[0].url === "https://kanbante-dev.dracodev.net/board/Projets/site", "lien tableau Kanbante"); +ok(items[0].context === "Projets/site · En cours", "contexte Kanbante"); + +/* nativeSearchUrl */ +ok(rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") === "https://tiktak-dev.dracodev.net/search?q=q", "repli TikTak"); +ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "https://og-dev.dracodev.net/", "repli ObsiGate = accueil (palette)"); + +console.log("recherche.test.js : " + n + " assertions OK"); From 0de3b6586989c562e799b44212ca9ac1b479d831 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:08:14 -0400 Subject: [PATCH 07/11] =?UTF-8?q?Recherche=20partout=20(#68)=20:=20routes?= =?UTF-8?q?=20/recherche=20+=20/api/search,=20draco=20sur=20/api/search,?= =?UTF-8?q?=20CORS=20born=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/app/main.py b/app/main.py index 4764288..414c351 100644 --- a/app/main.py +++ b/app/main.py @@ -390,6 +390,19 @@ class AuthMiddleware(BaseHTTPMiddleware): if (path.startswith("/mcp") or path.startswith("/api/")) \ and _bearer_user(request) is not None: return await call_next(request) + # Recherche partout (#68) : /api/search accepte aussi le jeton + # SSO draco_session (mono-utilisateur : un jeton valide désigne + # l'opérateur), comme les API de recherche des sœurs. Les + # preflights OPTIONS ne portent jamais de cookies : ils sont + # répondus par RechercheCorsMiddleware avant d'arriver ici. + if path == "/api/search": + try: + from app import draco + + if draco.claims_for(request) is not None: + return await call_next(request) + except Exception: + pass # API consumers get a plain 401 (no browser prompt, no # redirect they cannot follow); browsers get the login page. if path.startswith("/api/") or "text/html" not in request.headers.get( @@ -408,6 +421,15 @@ class AuthMiddleware(BaseHTTPMiddleware): app.add_middleware(AuthMiddleware) +# Recherche partout (#68) : CORS bornée pour GET /api/search — ajouté +# après AuthMiddleware donc exécuté avant lui (dernier ajouté = le +# plus externe) : les preflights OPTIONS sont répondus ici, avant +# tout refus d'authentification. +from app.recherche import RechercheCorsMiddleware + +app.add_middleware(RechercheCorsMiddleware) + + def _safe_next(raw: str) -> str: """Only local paths may be a post-login destination.""" if raw.startswith("/") and not raw.startswith("//") and "://" not in raw: @@ -4311,6 +4333,51 @@ async def search_page(request: Request, q: str = ""): ) +@app.get("/recherche", response_class=HTMLResponse) +async def recherche_page(request: Request, q: str = ""): + """Page de résultats unifiée « Rechercher partout » (fusion #68). + + Coquille seule : les résultats sont chargés côté client par + static/js/recherche.js. La page /search native reste inchangée. + """ + return render(request, "recherche.html", q=q.strip()) + + +@app.get("/api/search") +async def recherche_api(request: Request, q: str = "", limit: int = 20): + """Recherche JSON pour la page « Rechercher partout » (fusion #68). + + Même source que la page /search : recherche Gitea (titres + corps, + tous états, tous projets du workspace), groupée par dépôt avec la + colonne du tableau. Auth : voir AuthMiddleware (session, Basic, + jeton API, ou jeton SSO draco_session pour ce chemin). Les corps + sont tronqués à 160 caractères ; seuls les champs utiles sont + exposés (voir app/recherche.py). + """ + from app.recherche import groupes_vers_json + + q = q.strip() + limit = max(1, min(limit, 50)) + if not q: + return {"query": q, "groups": [], "total": 0} + workspace, _role = _current_workspace(request) + conn_ids = ( + models.workspace_connection_ids(workspace["id"]) + if workspace + else [registry.default_id] + ) + results: list[dict] = [] + for cid in conn_ids: + try: + found = await _forge_client(cid).search_issues(q=q, state="all") + results.extend(found) + except Exception as exc: + logger.debug("search failed for conn %s: %s", cid, exc) + groups = _group_issue_results(results) + data, total = groupes_vers_json(groups, limit) + return {"query": q, "groups": data, "total": total} + + @app.get("/my", response_class=HTMLResponse) async def my_issues(request: Request): """Open issues assigned to the token owner, per connection.""" @@ -6914,3 +6981,4 @@ async def readiness(): }, status_code=200 if ready else 503, ) + From b2d2fd1eee26675189976320a400e7f2f08a4dc1 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:08:18 -0400 Subject: [PATCH 08/11] Recherche partout (#68) : palette de la barre -> /recherche --- app/templates/_draco_bar.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/templates/_draco_bar.html b/app/templates/_draco_bar.html index 04d2696..9c7d43c 100644 --- a/app/templates/_draco_bar.html +++ b/app/templates/_draco_bar.html @@ -95,7 +95,7 @@ }; if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; } if (act === "rail") { var b = document.getElementById("side-collapse"); if (b) b.click(); return; } - if (act === "palette") { var p = document.querySelector(".palette-trigger"); if (p) { p.click(); } else { dire("Recherche & commandes : Ctrl+K."); } return; } + if (act === "palette") { location.href = "/recherche"; return; } if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; } /* v0.1.6 : le toast placeholder "fédération" est remplacé par le menu par défaut du nouveau JS — retourner false le @@ -119,3 +119,4 @@ + From 39750851b0b42fede3bf3b192b251bc8a3788674 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:13:03 -0400 Subject: [PATCH 09/11] =?UTF-8?q?Recherche=20partout=20(#68)=20:=20CORS=20?= =?UTF-8?q?r=C3=A9solue=20avec=20l'h=C3=B4te=20de=20la=20requ=C3=AAte=20(o?= =?UTF-8?q?rdre=20middlewares)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/recherche.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/app/recherche.py b/app/recherche.py index e3ff103..381c520 100644 --- a/app/recherche.py +++ b/app/recherche.py @@ -66,18 +66,24 @@ def origin_of(url: str | None) -> str | None: return f"{parsed.scheme}://{host}{port}" -def sister_origins(resolved: dict | None = None) -> frozenset: +def sister_origins( + resolved: dict | None = None, request_host: str | None = None +) -> frozenset: """Origines autorisées pour la lecture cross-origin de /api/search. ``resolved`` : dictionnaire ``draco_urls`` rendu par ``render()``. - Sans argument, le résolveur est appelé (import paresseux) ; en cas - d'échec, repli sur prod + dev officiels (premier parti uniquement). + Sans argument, le résolveur est appelé (import paresseux) avec + l'hôte de la requête quand il est connu — ce middleware s'exécute + avant tout poseur de contexte d'hôte, et sans lui la résolution + retomberait sur la configuration seule et raterait l'environnement + ``-dev`` ; en cas d'échec, repli sur prod + dev officiels + (premier parti uniquement). """ if resolved is None: try: from app.config import get_settings, resoudre_urls_draco - urls = resoudre_urls_draco(base=get_settings()) + urls = resoudre_urls_draco(hote_requete=request_host, base=get_settings()) resolved = { "obsigate": urls.sso_url, "flowdeck": urls.flowdeck_url, @@ -94,11 +100,13 @@ def sister_origins(resolved: dict | None = None) -> frozenset: return frozenset(origins) -def cors_headers_for(origin: str | None, resolved: dict | None = None) -> dict | None: +def cors_headers_for( + origin: str | None, resolved: dict | None = None, request_host: str | None = None +) -> dict | None: """En-têtes CORS d'une réponse de recherche, ou None si refusée.""" if not origin: return None - if origin.strip() not in sister_origins(resolved): + if origin.strip() not in sister_origins(resolved, request_host): return None return { "Access-Control-Allow-Origin": origin.strip(), @@ -107,9 +115,11 @@ def cors_headers_for(origin: str | None, resolved: dict | None = None) -> dict | } -def preflight_headers(origin: str | None, resolved: dict | None = None) -> dict | None: +def preflight_headers( + origin: str | None, resolved: dict | None = None, request_host: str | None = None +) -> dict | None: """En-têtes d'une réponse de preflight OPTIONS pour /api/search.""" - headers = cors_headers_for(origin, resolved) + headers = cors_headers_for(origin, resolved, request_host) if headers is None: return None headers.update({ @@ -176,8 +186,11 @@ class RechercheCorsMiddleware: for k, v in scope.get("headers", []) } origin = headers_in.get("origin", "") + request_host = ( + headers_in.get("host", "") or headers_in.get("x-forwarded-host", "") + ).split(",")[0].strip() if path == SEARCH_PATH and scope.get("method") == "OPTIONS": - preflight = preflight_headers(origin) + preflight = preflight_headers(origin, request_host=request_host) if preflight is not None: body = b"" headers_out = [(k.lower().encode("latin-1"), v.encode("latin-1")) @@ -193,7 +206,7 @@ class RechercheCorsMiddleware: async def send_with_cors(message): if message["type"] == "http.response.start": - cors = cors_headers_for(origin) + cors = cors_headers_for(origin, request_host=request_host) if cors is not None: message["headers"] = list(message.get("headers", [])) + [ (k.lower().encode("latin-1"), v.encode("latin-1")) From 725f78cb68773212ffcacaddcbaeaff966d316af Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:13:08 -0400 Subject: [PATCH 10/11] =?UTF-8?q?Recherche=20partout=20(#68)=20:=20test=20?= =?UTF-8?q?h=C3=B4te=20de=20la=20requ=C3=AAte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_recherche.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_recherche.py b/tests/test_recherche.py index b11cfb8..ecf6d1c 100644 --- a/tests/test_recherche.py +++ b/tests/test_recherche.py @@ -83,3 +83,10 @@ def test_groupes_vers_json_tronque_et_filtre(): def test_groupes_vers_json_vide(): assert groupes_vers_json([], limit=20) == ([], 0) assert groupes_vers_json(None, limit=20) == ([], 0) + + +def test_sister_origins_hote_requete(): + # Configuration muette + hôte dev : le repli inclut les -dev. + assert "https://og-dev.dracodev.net" in sister_origins( + {}, request_host="kanbante-dev.dracodev.net" + ) From d41a79a9ed410ee6a17955e78f7f05c7a4ee3f13 Mon Sep 17 00:00:00 2001 From: bruno Date: Wed, 28 Oct 2026 12:18:15 -0400 Subject: [PATCH 11/11] Recherche partout (#68) : JS compatible ESM (test ObsiGate) --- static/js/recherche.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/static/js/recherche.js b/static/js/recherche.js index 77aec9f..3f396bd 100644 --- a/static/js/recherche.js +++ b/static/js/recherche.js @@ -39,6 +39,10 @@ */ (function (global) { "use strict"; + /* globalThis : le fichier est chargé comme script classique dans les + * navigateurs, requis en CJS par les tests TikTak/Kanbante/Flowdeck, + * et importé en ESM par le test ObsiGate (frontend/js est "type": + * "module") — où `this` de niveau supérieur vaut undefined. */ var PROD = { obsigate: "https://og.dracodev.net/", @@ -501,4 +505,10 @@ } else { init(); } -})(typeof window !== "undefined" ? window : this); +})( + typeof window !== "undefined" + ? window + : typeof globalThis !== "undefined" + ? globalThis + : this +);