Audits · juin 2026Audit complet du code — 2026-06-15

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; lecture handler-self-feeder.ts:57 → loadPCMBlock
  • Problème : le code prétend gater la persistance sur User.pcmConsentAt, mais (a) ce champ n'existe pas dans prisma/schema.prisma, (b) schedulePostStreamPcm ne vérifie aucun consentement avant persistPCMDetection, (c) loadPCMBlock ré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.pcmConsentAt au 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 sur trackInternalLlmUsage({ 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 : resolveBridge ne gate que sur getStoreAccess (appartenance), sans hasAccessPermission(access, "store.update"). Le set du viewer est en lecture seule (permissions.ts:100), pourtant il peut updateThemeAsset/deleteThemeAsset sur 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-Id est lu et stocké en metadata mais jamais dédupliqué (pas d'équivalent du StripeEvent). Shopify rejoue/double-livre orders/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 passe thread.id (constant pour tout le fil). Conséquence : les confirmations ne sont jamais comptées (confirmationCount figé) 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()}) et whatsapp-per-store.ts:560 sont 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:423 vs api/chat/messages/[id]/route.ts:61
  • Problème : en live, message.id est l'id client (le transport n'adopte jamais l'id DB). Le PATCH résout via findUnique({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 passe useKernel({ 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 (+ onError mémoïsé), retirer options.

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) vs chat-banners.tsx:97,140 (EN), montées ensemble dans ai-chat.tsx:958 et :1236
  • Problème : au même état "out of credits", CreditsLimitBanner (FR) s'affiche au-dessus de l'input et ChatOutOfCreditsBanner (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/hasAccessPermission et 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 dans run-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 reactionByMessageId depuis metadata.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 le fetch ; 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 à savedMessageIds uniquement sur res.ok ; retirer en échec.

M5 — Changement de version (branch) inopérant après rechargement

  • Où : ai-chat.tsx:427 (sauve parentMessageId = id client) vs use-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 updated hors updater, puis persistConversation après setMessages.

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-banner codé en dur collisionne si deux chats montent ; setTimeout sans 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} ou role="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 vocal composer-right-actions.tsx:137 ; copier sous chaque bloc de code code-block.tsx:150 ; croix de fermeture des erreurs chat-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 ; FileManager file-manager.tsx:468,477,503 ; champs Tags/List entity-form-fields.tsx:41,60,98,118 ; retours CreditsDropdown shared/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.
  • 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 (auth bei_…/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 — #noremember aveugle 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 : setTimeout de 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 barrel chat/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 any mineurs : router web-preview/buttons.tsx via electron-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.5 existe 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.ts pé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.update sur theme-assets PUT/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.update sur ledger/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-label sur 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.