Date : 15/02/2026 Status : IMPLEMENTEE Auditeur : Claude Opus 4.6 Methode : Lecture integrale de chaque fichier, tracage des code paths reels Scope : 16 fichiers source + 2 composants complementaires
Le projet est globalement bien structure. L'architecture "custom event bus via document.dispatchEvent" est coherente et evite les pieges Qwik de serialisation. Le WebSocket est correctement isole dans un useVisibleTask$ (pas dans useSignal). Cependant, plusieurs problemes meritent attention, classes par severite.
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/sidebar/ConversationSidebar.tsx
Lignes : 151-156
const origShowArchived = showArchived.value;
const archiveInterval = setInterval(() => {
if (showArchived.value !== origShowArchived) {
loadConversations();
}
}, 500);
Analyse : Ce n'est PAS une boucle infinie de requetes reseau. Le setInterval tourne toutes les 500ms, mais il compare showArchived.value avec sa valeur initiale origShowArchived. La requete loadConversations() ne se declenche QUE si la valeur a change.
Probleme reel : L'intervalle est un workaround pour detecter un changement de signal Qwik depuis l'interieur d'un useVisibleTask$. C'est un pattern sous-optimal pour deux raisons :
origShowArchived est capture a la creation. Apres le premier changement, showArchived.value !== origShowArchived reste TOUJOURS vrai, donc chaque tick de 500ms declenchera un loadConversations() en boucle indefiniment.Scenario : L'utilisateur clique "Voir archivees". showArchived passe a true. A partir de ce moment, le setInterval appelle loadConversations() toutes les 500ms sans jamais s'arreter.
De plus : Le bouton "Voir archivees" (lignes 537-548) fait deja un fetch direct dans son onClick$. Donc le premier rechargement se fait immediatement, mais ensuite le polling 500ms continue a re-fetcher en boucle.
Verdict : BUG CONFIRME. Apres le premier toggle de showArchived, les conversations sont re-fetched toutes les 500ms indefiniment.
Fix suggere : Supprimer le archiveInterval. Le bouton "Voir archivees" fait deja le fetch directement dans son handler onClick$ (lignes 537-548). Pas besoin de polling.
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/lib/ws-manager.ts
Lignes : 6-7, 114-125
const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
Analyse : Apres 5 tentatives echouees (total ~31 secondes), le WS ne tentera plus jamais de se reconnecter. L'utilisateur verra un badge "Deconnecte" sans possibilite de retablir la connexion autrement qu'en rechargeant la page.
Probleme : Pas de mecanisme de recovery. Pas de bouton "Reconnecter". Une fois les 5 tentatives consommees, l'application est en mode dead-socket jusqu'au prochain F5.
Le compteur reconnectAttempts est reset a 0 sur auth_success (ligne 71), ce qui est correct pour les reconnexions reussies. Mais si le serveur est down 2 minutes (maintenance rapide), l'utilisateur est bloque.
Fix suggere :
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/sidebar/ConversationSidebar.tsx
Lignes : 211-225
const deleteConversation = $(async (convId: string) => {
const apiKey = localStorage.getItem('ulias_api_key');
if (!apiKey) return;
try {
await fetch(`${API_URL}/api/conversations/${convId}`, {
method: 'DELETE',
headers: { 'X-API-Key': apiKey },
});
conversations.value = conversations.value.filter(c => c.id !== convId);
...
Analyse : Le DELETE est execute immediatement, sans confirm() ni modal de confirmation. Un clic accidentel sur le bouton supprimer (visible au hover) detruit la conversation definitivement.
Meme probleme dans : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/history/index.tsx lignes 85-95 (deleteObjective).
Fix suggere : Ajouter un if (!confirm('Supprimer cette conversation ?')) return; ou une modale.
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/lib/markdown.ts
Lignes : 15-18
export function renderMarkdown(md: string): string {
if (!md) return '';
let html = escapeHtml(md);
// Code blocks (``` ... ```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, ...);
Analyse : L'echappement HTML est fait EN PREMIER, puis les patterns markdown sont appliques. Voyons le flux pour un input contenant du code :
`<div>test</div>``<div>test</div>`<code class="bg-base-300 px-1 rounded text-xs font-mono"><div>test</div></code>Le HTML est correctement echappe dans le code inline. Pas de double-echappement ici, le flux est correct.
MAIS il y a un vrai probleme avec les code blocks :
```\n<div>test</div>\n`````` (inchanges) mais \n aussi inchange, <div> devient <div>/```(\w*)\n([\s\S]*?)```/g cherche un vrai \n. Or escapeHtml ne touche pas les newlines, donc la regex fonctionne.Resultat : Le code HTML dans les blocs affichera <div> au lieu de <div>. C'est le comportement ATTENDU (on veut voir le code source, pas l'executer). Pas de double-echappement.
Verdict : PAS de bug de double-echappement. Le flux escapeHtml-avant-markdown est correct pour ce cas d'usage. Mais il y a un probleme connexe :
[text](url) sont construits APRES l'echappement HTML. La regex cherche \[([^\]]+)\]\(([^)]+)\). Si l'URL contient &, elle sera deja echappee en &. Le lien <a href="url&param=val"> sera genere avec & dans le href. Les navigateurs decodent & dans les attributs href, donc le lien fonctionne quand meme. Pas de bug utilisateur visible.Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/lib/ws-manager.ts
Lignes : 93-101
send(content: string) {
if (this._status !== 'connected') return;
this.sendRaw({ type: 'message', apiKey: this.apiKey, content });
}
sendCommand(cmd: string) {
if (this._status !== 'connected') return;
this.sendRaw({ type: 'command', apiKey: this.apiKey, cmd });
}
Analyse : L'API key est envoyee dans CHAQUE message WS, pas seulement dans le message d'auth initial. Cela augmente la surface d'exposition si les logs du serveur capturent les messages WS.
Contexte atenuant : La connexion est WSS (chiffree), et le service est en private:true (nginx). Le risque est faible.
Fix suggere : Apres authentification reussie, ne plus inclure l'apiKey dans les messages subsequents (utiliser un token de session cote serveur).
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/layout.tsx
Lignes : 166-169
setTimeout(() => {
document.dispatchEvent(new CustomEvent('ulias:load-history', { detail: { messages: items } }));
}, 500);
Analyse : Le chargement de la derniere conversation est retarde de 500ms avec setTimeout pour "laisser ChatMessages mount". Si le composant ChatMessages n'est pas monte en 500ms (appareil lent, tab en arriere-plan), l'evenement sera dispatche mais personne ne l'ecoutera, et l'historique ne s'affichera pas.
Inversement, si la page est rapide, l'utilisateur voit un chat vide pendant 500ms avant que l'historique n'apparaisse.
Fix suggere : Utiliser un pattern "ready" : ChatMessages dispatche un evenement ulias:chat-ready dans son useVisibleTask$, et layout attend cet evenement avant de charger l'historique.
Fichiers concernes : Presque tous
Pattern observe :
} catch { /* silent */ }
Ce pattern est present dans :
Analyse : Aucune erreur reseau n'est jamais affichee a l'utilisateur pour les operations secondaires. Si le serveur repond 500 ou si le reseau coupe, l'utilisateur ne sait pas que son action a echoue.
Cas le plus critique : deleteConversation (F03) — si le DELETE echoue cote serveur, la conversation est quand meme supprimee de la vue locale (ligne 220: conversations.value = conversations.value.filter(...)). L'utilisateur croit avoir supprime mais la conversation est toujours la cote serveur.
Fix suggere : Au minimum, ne modifier l'etat local qu'APRES verification de res.ok. Eventuellement afficher un toast d'erreur.
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/ChatMessages.tsx
Ligne : 22
const streamInfo = useStore<StreamingInfo>({ ... });
Analyse : useStore est utilise pour les infos de streaming, et chaque propriete est modifiee individuellement (lignes 41-85). Les proprietes sont lues dans le JSX render (lignes 297-308).
Le risque du useStore dans le render est le meme que pour .sort() : si une mutation est faite PENDANT le render, cela peut causer une boucle. Ici, les mutations se font dans les event listeners (pas dans le render), donc pas de boucle infinie.
Verdict : Pas de bug. Le pattern est correct car les mutations sont declenchees par des events, pas par le render.
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/teams/index.tsx
Lignes : 42-52
useVisibleTask$(async () => {
try {
const res = await fetch(`${API_URL}/api/teams`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
Analyse : Le fetch vers /api/teams ne passe PAS de header X-API-Key, contrairement a tous les autres endpoints. C'est soit un oubli (et la requete echouera si l'API exige l'auth), soit l'endpoint est volontairement public.
Risque : Si l'endpoint est public, il expose la liste des equipes et agents a quiconque. Si l'endpoint exige l'auth, cette page sera toujours en erreur.
Fix suggere : Ajouter le header X-API-Key comme les autres pages.
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/history/index.tsx
Lignes : 85-95
const deleteObjective = $(async (id: string) => {
const apiKey = localStorage.getItem('ulias_api_key');
if (!apiKey) return;
try {
await fetch(`${API_URL}/api/objectives/${id}`, {
method: 'DELETE',
headers: { 'X-API-Key': apiKey },
});
objectives.value = objectives.value.filter(o => o.id !== id);
} catch { /* silent */ }
});
Meme probleme que F03 : suppression immediate sans confirmation. De plus, l'etat local est modifie AVANT verification du succes de la requete (le catch ignore les erreurs).
Fichiers :
/stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/EventCard.tsx lignes 407, 423/stock_8to/33800-stack/projects/ulias-org-web/src/routes/history/index.tsx ligne 376dangerouslySetInnerHTML={renderMarkdown(summary)}
Analyse : Le renderMarkdown fait un escapeHtml en premier, ce qui neutralise les balises HTML injectees. Cependant, la regex des liens (ligne 46 de markdown.ts) genere des <a href="...">. Si un attaquant controle l'output d'un agent (via tool_result par exemple), il pourrait injecter un lien avec un scheme javascript: :
Input : [click](javascript:alert(1))
Apres escapeHtml : [click](javascript:alert(1)) (pas de caractere HTML a echapper)
Apres regex : <a href="javascript:alert(1)" ...>click</a>
Toutefois : Le target="_blank" rel="noopener" est present, et les navigateurs modernes bloquent javascript: dans les liens. Le risque reel est minimal, mais le pattern est a noter.
Fix suggere : Filtrer les URLs dans la regex de liens pour n'accepter que http:, https: et mailto:.
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/settings/index.tsx
Lignes : 52-60
const res = await fetch(`${API_URL}/api/profile`, {
method: 'PUT',
headers: { ... },
body: JSON.stringify({ preferences: profile.value }),
});
Analyse : Le PUT envoie { preferences: profile.value }. Si le profil a d'autres champs que preferences, le PUT pourrait les ecraser. Cependant, le body ne contient que preferences, donc si le backend ignore les champs absents, c'est OK.
Risque reel : Depend de l'implementation du backend. Si le backend fait un merge, pas de probleme. Si le backend remplace le profil entier, les champs non-preferences seront perdus.
Conforme a la regle : Le CLAUDE.md note "PUT = remplace TOUT". Mais ici, le frontend envoie un objet partiel sous la cle preferences, pas le profil entier. Le risque est faible si le backend traite preferences comme un sous-objet.
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/EventCard.tsx
Lignes : 486-494
onClick$={() => {
feedbackGiven.value = -1;
showComment.value = true;
}}
Puis bouton "Passer" (lignes 543-549) :
onClick$={() => {
submitFeedback(-1);
showComment.value = false;
}}
Analyse : Quand l'utilisateur clique "-" (negative feedback), feedbackGiven.value est mis a -1 MAIS submitFeedback n'est PAS appele. Un formulaire de commentaire s'affiche. Si l'utilisateur clique "Passer", submitFeedback(-1) est appele.
Si l'utilisateur clique "Envoyer" (avec commentaire), submitWithComment() est appele, qui fait un second POST. Pas de double envoi dans le flow normal.
Verdict : Pas de bug. Le flow est correct : le premier clic sur "-" n'envoie rien, il attend le commentaire ou le "Passer".
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/layout.tsx
Lignes : 29-30
if (window.location.pathname === '/login' || window.location.pathname === '/login/') return;
Analyse : Le check est fait avec window.location.pathname (string exact), pas avec un match pattern. Si une future route comme /login/callback est ajoutee, le WS se connectera dessus. Ceci est une remarque de robustesse, pas un bug actuel.
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/sidebar/ConversationSidebar.tsx
Lignes : 215-220
await fetch(`${API_URL}/api/conversations/${convId}`, {
method: 'DELETE',
headers: { 'X-API-Key': apiKey },
});
conversations.value = conversations.value.filter(c => c.id !== convId);
Analyse : La modification de l'etat local (filter) est faite APRES le fetch, mais sans verification de res.ok. Si le serveur repond 403, 404 ou 500, le fetch ne throw pas (seules les erreurs reseau throw). La conversation est quand meme retiree de la vue.
Meme probleme dans : archiveConversation, togglePin, saveRename (mais pour ceux-ci, la consequence est moins grave — la vue sera corrigee au prochain rechargement).
Fix suggere :
const res = await fetch(...);
if (res.ok) {
conversations.value = conversations.value.filter(c => c.id !== convId);
}
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/history/index.tsx
Lignes : 79-83
const retryObjective = $((description: string) => {
document.dispatchEvent(new CustomEvent('ulias:send', { detail: { content: description } }));
window.location.href = '/';
});
Analyse : window.location.href = '/' fait un rechargement complet de la page (full page navigation), pas un routing Qwik. Cela :
ulias:send dispatche AVANT la navigation sera perdu car les listeners seront detruits par la navigationVerdict : L'evenement ulias:send est dispatche puis immediatement la page est rechargee. Le message WS peut ou non etre envoye avant la destruction du WS. Race condition reelle.
Fix suggere : Utiliser le useNavigate() de Qwik-City au lieu de window.location.href, et envoyer le message apres la navigation (ou stocker dans localStorage pour le reprendre).
Partiellement confirme. Ce n'est pas une boucle infinie de requetes des le demarrage. C'est une boucle infinie de requetes APRES le premier toggle de showArchived. Voir F01 pour les details.
Non confirme. Le flux escapeHtml-avant-markdown est correct. Voir F04 pour l'analyse detaillee.
| ID | Severite | Description |
|---|---|---|
| F01 | MOYENNE | Sidebar polling 500ms: boucle infinie de requetes apres toggle showArchived |
| F02 | MOYENNE | WS: 5 tentatives max sans recovery, utilisateur bloque |
| F03 | MOYENNE | Suppression conversation sans confirmation |
| F10 | MOYENNE | Suppression objectif sans confirmation |
| F15 | MOYENNE | Etat local desynchronise apres operation serveur echouee |
| F06 | FAIBLE | Race condition chargement initial (setTimeout 500ms) |
| F07 | FAIBLE | Erreurs reseau silencieuses partout |
| F09 | FAIBLE | Endpoint /api/teams sans auth header |
| F11 | FAIBLE | XSS potentiel via javascript: dans les liens markdown |
| F16 | FAIBLE | retryObjective: window.location.href perd le message |
| F04 | FAIBLE | Markdown: pas de bug mais liens avec & echappes en & dans href |
| F05 | FAIBLE | API key dans chaque message WS (pas seulement auth) |
| F12 | FAIBLE | Settings PUT sans certitude que le backend merge |
| F14 | INFO | Check /login en string exact, pas en prefix |
useVisibleTask$ ont un cleanup() qui retire les event listeners et clear les intervals.archiveInterval dans ConversationSidebar — le fetch est deja fait dans le handler onClick$.confirm() avant les suppressions.res.ok avant de modifier l'etat local apres un fetch.