Audit complet du code — 2026-06-15
Audit post-merge PR #298. Cinq inspecteurs en parallèle (sécurité, facturation, pipeline IA/mémoire, frontend/a11y/i18n, tests/dead-code/CI), chaque constat vérifié par lecture du code réel (fichier:ligne). Les…
Audit post-merge PR #298. Cinq inspecteurs en parallèle (sécurité, facturation, pipeline IA/mémoire, frontend/a11y/i18n, tests/dead-code/CI), chaque constat vérifié par lecture du code réel (fichier:ligne). Les correctifs de la PR #298 sont exclus et confirmés corrects (voir §Vérifié).
Verdict global
Base de code mature : SSRF (DNS-rebinding) correctement gardé partout, webhooks signés (Stripe/Shopify/WhatsApp), SQL paramétré, idempotency Stripe prouvée par tests. Les problèmes se concentrent sur : 1 défaut de consentement (RGPD), 2 trous RBAC, 1 webhook Shopify non-idempotent, des fuites de marge sur des appels IA internes, plusieurs bugs de réconciliation client/serveur dans le chat, et une dette i18n + a11y + tests.
🔴 CRITIQUE
C1 — Détection de personnalité (PCM) sans consentement + champ inexistant
- Où :
src/features/ai/orchestrator/runtime/handler-onfinish.ts:108→handler-post-stream.ts:104→personality/db.ts:73; lecturehandler-self-feeder.ts:57→loadPCMBlock - Problème : le code prétend gater la persistance sur
User.pcmConsentAt, mais (a) ce champ n'existe pas dansprisma/schema.prisma, (b)schedulePostStreamPcmne vérifie aucun consentement avantpersistPCMDetection, (c)loadPCMBlockréinjecte le profil dans le prompt sans contrôle. Chaque conversation est profilée (type Kahler) et le profil réutilisé, sans consentement. Défaut RGPD réel. - Correctif : ajouter
User.pcmConsentAtau schéma et gater écriture et lecture dessus. Tant que non-fait, ne pas persister/injecter. - Bonus (marge) : ce même appel Haiku PCM (
personality/detect.ts:18) n'est pas facturé (contrairement à auto-title/memory), fuite à brancher surtrackInternalLlmUsage({ channel: "pcm-detect" })une fois le consentement réglé.
🟠 ÉLEVÉ
H1 — Rôle viewer peut écrire/supprimer les thèmes Shopify en prod (RBAC cassé)
- Où :
src/app/api/stores/[storeId]/theme-assets/route.ts:30(gate), PUT:94, DELETE:129 - Problème :
resolveBridgene gate que surgetStoreAccess(appartenance), sanshasAccessPermission(access, "store.update"). Le set duviewerest en lecture seule (permissions.ts:100), pourtant il peutupdateThemeAsset/deleteThemeAssetsur le thème live. La route canonique (stores/[storeId]/route.ts:190) gate bien cette permission ; celle-ci l'omet. - Correctif : ajouter
if (!hasAccessPermission(access, "store.update")) return 403.
H2 — Webhooks métier Shopify non-idempotents (effets en double)
- Où :
src/app/api/webhooks/shopify/events/route.ts:106 - Problème : le
X-Shopify-Webhook-Idest lu et stocké en metadata mais jamais dédupliqué (pas d'équivalent duStripeEvent). Shopify rejoue/double-livreorders/create,inventory_levels/update… → ré-ingestion, écritures d'index de connaissance et snapshots de ledger en double. - Correctif : table d'idempotency (unique
webhookId) ou claim atomique avant traitement.
H3 — Mémoire bot/WhatsApp : l'id de thread sert d'id de message → changements d'avis ignorés
- Où :
src/features/ai/bot/bot-handlers.ts:648(messageId: thread.id) →memory/write.ts:107 - Problème : clé d'idempotency =
(scopeId, agentId, key, sourceMessageId). Le bot passethread.id(constant pour tout le fil). Conséquence : les confirmations ne sont jamais comptées (confirmationCountfigé) et une contradiction (ex. ton de marque "playful" → "serious") est silencieusement jetée avant le résolveur de conflit. Le chat web (web:${Date.now()}) etwhatsapp-per-store.ts:560sont OK. - Correctif : passer un id par-message (
${thread.id}:${createdMessage.id}).
H4 — Feedback 👍/👎 perdu sur un message tout juste streamé (404)
- Où :
message-text-part.tsx:423vsapi/chat/messages/[id]/route.ts:61 - Problème : en live,
message.idest l'id client (le transport n'adopte jamais l'id DB). Le PATCH résout viafindUnique({id})→ null → 404. Le feedback riche (RLHF) est perdu pour tout message noté avant un rechargement. - Correctif : réconcilier l'id serveur (réponse du POST
saveMessage) sur le message en session avant d'autoriser le PATCH.
H5 — Route vocale EVI : tout le français est codé en dur (contourne next-intl)
- Où :
src/app/api/evi/chat/completions/route.ts:353(refus parlé),:496(VOICE_OVERLAY),:514(getVoiceFallbackPrompt) - Problème : message "crédits épuisés", overlay vocal et prompt de repli sont en français codé en dur. Un opérateur non-francophone entend du français quelle que soit sa locale.
- Correctif : résoudre la locale (store/org déjà chargés dans
resolveStoreVoiceContext) et tirer ces chaînes/prompts de ressources par-locale.
H6 — useKernel : effet d'hydratation qui re-déclenche à chaque rendu (boucle de fetch)
- Où :
src/features/ai/preview/store-kernel/hooks/use-kernel.ts:188 - Problème : deps
[options.storeID, options.onError, options], l'appelant passeuseKernel({ storeID })(objet neuf à chaque rendu) → l'effet re-tourne →fetch→setState→ re-rendu → re-fetch. Tempête de requêtes. - Correctif : dépendre seulement de
options.storeID(+onErrormémoïsé), retireroptions.
H7 — Bannières "crédits épuisés" : français ET anglais affichés en même temps
- Où :
shared/credits/credits-limit-banner.tsx:46,74,103(FR) vschat-banners.tsx:97,140(EN), montées ensemble dansai-chat.tsx:958et:1236 - Problème : au même état "out of credits",
CreditsLimitBanner(FR) s'affiche au-dessus de l'input etChatOutOfCreditsBanner(EN) en dessous, simultanément, en deux langues. Des clés localisées existent déjà (chat.errors.creditsExhausted). - Correctif : router les deux via
useTranslations, n'afficher qu'une bannière.
H8 — Frontière d'autorisation multi-tenant (RBAC) sans aucun test
- Où :
src/lib/security/tenant-auth.ts,src/lib/security/permissions.ts(0 test) - Problème :
getOrgAccess/getStoreAccess/hasMinRole/hasAccessPermissionet le résolveur de permissions sont des fonctions pures gating chaque route par-ressource. Une régression silencieuse = fuite de données entre clients (pas un 500). Ce sont les tests les moins chers à plus forte valeur. - Correctif : suite unitaire (shortcut owner → fallback member → null, hiérarchie de rôles, overrides de permission).
🟡 MOYEN
M1 — viewer peut annuler le contexte @Atlas du store sans permission d'écriture
- Où :
src/app/api/stores/[storeId]/ledger/[actionId]/revert/route.ts:39(mutation:160) - Correctif : ajouter un gate
hasAccessPermission(access, "store.update").
M2 — Skills du wizard non facturés → ✅ DÉCISION : subvention onboarding assumée
- Où :
wizard-skills/run-milestone.ts(exécuteurs blog/product/collection/page/seo/legal) - Analyse de coût : tout sur Haiku 4.5 (le tier le moins cher) ; ~0,25 $ de coût brut pour un onboarding COMPLET (~35 appels), soit < 1 % d'un mois de plan Pro.
- Décision (15/06) : NE PAS facturer, choix stratégique, pas une fuite. L'onboarding
turnkey est le levier de conversion ; son coût est récupéré largement par la commission
partenaire Shopify (transfert d'ownership) + l'activation du plan (49–299 $/mois). Facturer
saboterait le flux qui rapporte le plan. Le coût de re-run est borné par le claim atomique
queued→running→done+ l'idempotency des seeders (pas de cap nécessaire). Documenté en clair dansrun-milestone.ts.
M3 — État des réactions 👍/👎 local seulement (jamais réhydraté)
- Où :
use-chat-composer-state.ts:185; la donnée existe pourtant (metadata.feedback) - Correctif : seeder
reactionByMessageIddepuismetadata.feedback.ratingà l'hydratation.
M4 — saveMessage marque "sauvé" avant le POST → un échec transitoire perd le message
- Où :
use-chat-persistence.ts:270 - Problème :
savedMessageIds.add(id)avant lefetch; en échec, l'id reste, donc tout retry est un no-op silencieux → message perdu (alors que le tour a pu être facturé). - Correctif : ajouter à
savedMessageIdsuniquement surres.ok; retirer en échec.
M5 — Changement de version (branch) inopérant après rechargement
- Où :
ai-chat.tsx:427(sauveparentMessageId= id client) vsuse-chat-branches.ts:126 - Problème : au reload le timeline porte des ids serveur ;
findIndexéchoue → le switch est un no-op. Le groupage marche, le clic ne fait rien. - Correctif : persister l'id serveur du message user comme
parentMessageId.
M6 — PUT réseau déclenché dans un updater setMessages (double-fire en Strict Mode)
- Où :
components/contexts/conversation-context.tsx:339 - Correctif : calculer
updatedhors updater, puispersistConversationaprèssetMessages.
M7 — Bannière d'erreur supprimée en DOM impératif (getElementById + remove())
- Où :
chat-banners.tsx:112 - Problème : React croit le nœud monté ; id
error-bannercodé en dur collisionne si deux chats montent ;setTimeoutsans cleanup. - Correctif : passer le dismiss en state React + rendu conditionnel.
M8 — Statut des sous-agents par couleur seule (a11y)
- Où :
subagents-team/components/subagent-card.tsx:35 - Correctif :
title/aria-label={status}ourole="status"+ texte sr-only.
M9 — Note Core Web Vitals par couleur seule (point aria-hidden)
- Où :
cards/vitals-card.tsx:61 - Correctif : label sr-only ("poor") ou icône par note.
M10 — ~30 boutons icône-seule sans nom accessible (a11y)
- Surfaces les plus visibles :
- Chat : bouton « + »
composer-plus-menu.tsx:200; appel vocalcomposer-right-actions.tsx:137; copier sous chaque bloc de codecode-block.tsx:150; croix de fermeture des erreurschat-banners.tsx:109; sélecteur de modèle/store en mode compact (composer-model-select.tsx:77,multi-org-store-picker.tsx:195). - Réglages store : sync/suppr/ajout source
rules-manager.tsx:251,259,294. - Design system : menu « … » header
platform-center.tsx:235; FileManagerfile-manager.tsx:468,477,503; champs Tags/Listentity-form-fields.tsx:41,60,98,118; retours CreditsDropdownshared/credits/dropdown.tsx:95,166. - Admin (interne) : triggers filtres/« … »
users-table.tsx:170,335,subscriptions-panel.tsx:119,store-detail.tsx:292,user-detail.tsx:368,org-detail.tsx:328.
- Chat : bouton « + »
- Correctif : ajouter
aria-label(ou<span className="sr-only">) à chacun.
M11 — Chemins argent/auth critiques sans tests unitaires
- Où :
billing.ts(swap hold→usage),handler-prestream-gate.ts/handler-cost-guard.ts(estimation + cap),lib/security/crypto.ts(chiffrement token at-rest),lib/security/api-keys.ts(authbei_…/MCP),webhooks/shopify/events(HMAC) - Correctif : suites unitaires ciblées (round-trip crypto, double-facturation, refus 402).
🟢 FAIBLE
- L1 — Abort cost-guard WhatsApp/Meta facture 0 token (consommation partielle non-facturée).
bot-handlers.ts:524. - L2 — Voix EVI : floor mais pas de réservation → race de dépense concurrente (2 tours simultanés sur solde bas).
evi/chat/completions/route.ts:348. Correctif :reserveCreditsForChannel("voice"). - L3 —
trackUsage: le retry peut ré-écrire une ligne usage si la txn a commité mais la réponse est perdue (Neon pooled reset).billing.ts:166. Correctif : clé d'idempotency sur la ligne usage. - L4 —
#norememberaveugle au multi-part (latent, non déclenchable aujourd'hui).memory/index.ts:83. - L5 — Cost guard de délégation : fenêtre de concurrence (fan-out parallèle de sous-agents compté seulement au retour).
delegate-tool.ts:259. - L6 — Beacons vitals/pixels : confiance à l'
Origin(falsifiable) + pas de rate limit + docstring trompeur.vitals/ingest,pixels/ingest. - L7 —
domain-verification.tsx:31:setTimeoutde reset sans cleanup. - L8 —
connectors-popover.tsx:76: état d'erreur connecteur en couleur seule sur le trigger.
🧹 Ménage / dette
- DC1 — Supprimer
src/components/patterns/ai-elements/chat/status-card.tsx: les 13 exports sont morts (aucun consommateur hors barrel). Système vivant =emit_card→CardRouter→cards/*+ bannières live. Retirer aussi le bloc barrelchat/index.ts:408-420. Supprime au passage M9 (ReportCard) et un cas a11y couleur-seule. (knip ne le voit pas car re-exporté par le barrel.) - DC2 — Roster fictif
execution-planner/agent-avatar.tsx: 7 rôles inventés (strategic_director,ux_designer…) déconnectés du vrai roster (Atlas + Maya/Marco/Otis/Faye/Sam,team-roster.ts). Remplacer par le vrai roster, ou marquer explicitement décoratif. - DC3 —
as anymineurs : routerweb-preview/buttons.tsxviaelectron-bridge.ts; typer(authClient as any).stripe(~/billing/page.tsx:145). - DC4 — TODO notables : KPI revenue incomplet (
kpi-snapshot.ts:103, table payment-events manquante) ; runner de workflow durable encore stub (api/workflow/[id]/run).
✅ Vérifié correct (rassurance)
- Les 3 correctifs facturation de la PR #298 (metering Haiku interne, metering+floor EVI,
release du hold sur erreur) : confirmés corrects, idempotents, sans double-facturation. La
clé de prix
claude-haiku-4.5existe bien dans la table. - Rebranch contexte prompt (
handler-prompt-kernel.ts:200) :buildContextAdditions+ overlay activité appendés une fois,storeRules: null→ pas de double injection. - SSRF :
validateScanUrl(DNS-resolve + blocage IP privées/metadata) appliqué sur toutes les surfaces de fetch d'URL utilisateur. Pas de gap DNS-rebinding. - Idempotency Stripe (
webhooks.integration.spec.ts), race de réservation crédits (credits-check.integration.spec.ts), schema-guard : bien testés. - Cycle streaming (
onError/onAbort/onFinish+usageSettled+consumeStream) : solide. - Erreur Vercel preview instantanée = bénigne/attendue (build tolère l'absence de DB ;
si hard-fail, c'est un
schema-guard.generated.tspérimé →pnpm db:guard, pas un souci de config).
Checklist priorisée
À faire en premier (correctness / conformité)
- C1 — Consentement PCM : ajouter
User.pcmConsentAt, gater écriture + lecture. - H1 — Gate
store.updatesurtheme-assetsPUT/DELETE. - H2 — Idempotency webhooks Shopify (
X-Shopify-Webhook-Id). - H3 — Id par-message dans la mémoire bot/WhatsApp.
- H4 — Réconcilier l'id serveur pour le feedback 👍/👎.
- M1 — Gate
store.updatesurledger/revert.
Ensuite (UX + i18n + fuites de marge)
- H5 — i18n route vocale EVI.
- H6 — Correctif de la boucle
useKernel. - H7 — Une seule bannière crédits, localisée.
- M2 — Subvention onboarding assumée (décision 15/06, documentée dans
run-milestone.ts). - M3/M4/M5 — Réactions hydratées, saveMessage robuste, branch-switch persistant.
- C1-bonus / L1 / L2 : Facturer PCM, abort WhatsApp partiel, réservation EVI.
Solidité long terme (tests)
- H8 — Tests RBAC
tenant-auth+permissions. - M11 — Tests billing hold-swap, cost-guard, crypto, api-keys, webhook Shopify.
Accessibilité & langues
- M10 —
aria-labelsur les ~30 boutons icône-seule. - M8/M9/L8 — Statuts pas seulement par couleur.
Ménage
- DC1 — Supprimer
status-card.tsx(mort). - DC2 — Roster réel dans
agent-avatar.tsx. - DC3/DC4 —
as any, TODO KPI/workflow.