ArchitectureExtension Presence Contract : BoostEcom Spy ↔ la home BoostEcom

Extension Presence Contract : BoostEcom Spy ↔ la home BoostEcom

Comment la plateforme sait que l'extension Chrome BoostEcom Spy (worktree Extensions/@BoostEcom) est installée sur le navigateur qui lit la page. Consommé par ChromeExtensionBadge via…

Comment la plateforme sait que l'extension Chrome BoostEcom Spy (worktree Extensions/@BoostEcom) est installée sur le navigateur qui lit la page. Consommé par ChromeExtensionBadge via ConversationProvider.chromeExtensionConnected. Contrat voisin, autre sujet : panel-ingest-contract.md (envoi des events de navigation, serveur → HMAC).

Le fait de base

Une page web ne peut pas détecter seule une extension installée. Chrome n'expose aucune API à la page pour ça, et c'est délibéré (ce serait un vecteur de fingerprinting). La détection n'existe que si l'extension s'annonce. Toute la question est donc : par quel canal, et à quel coût côté manifest.

C'est la cause racine du bug historique : le badge de la home affichait « Install Chrome » à des opérateurs qui avaient déjà l'extension, parce que les deux canaux implémentés (marker DOM, ping runtime) demandaient tous les deux une coopération que le build publié ne fournit pas.

Les trois signaux, du moins cher au plus cher

chromeExtensionConnected est le OU de trois signaux indépendants (src/components/contexts/conversation-context.tsx). Un seul suffit ; aucun n'est requis. Tous sont définis dans src/features/store-runtime/extension/types.ts et épinglés par presence.test.ts.

Le nom canonique a changé deux fois, et la seconde fois était gratuite. data-contextiq (publié) → data-boostecom-spy (introduit le 2026-09-12) → data-boostecom-extension (2026-09-13). Le second renommage n'a coûté rien : aucun build publié n'avait jamais écrit data-boostecom-spy, donc il n'y avait aucun parc à migrer. C'est la seule fenêtre où le nom d'un contrat est libre de bouger, et elle se referme le jour où un package part. La direction vient du dépôt de l'extension, pas d'un goût : sa propre section [Unreleased] renomme l'utm_campaign post-install de boostecom_spy en boostecom_extension. « Spy » reste le nom du PRODUIT d'intelligence que l'extension expose, pas celui de l'extension.

#SignalCe que ça coûte à l'extensionQuand ça flippe le badge
1Marker DOM data-boostecom-extension (ou data-contextiq, legacy) sur <html>une ligne dans le content scriptau chargement
2postMessage boostecom-extension-*rien — déjà envoyéà la 1ʳᵉ utilisation, ou au chargement si le hello est implémenté
3chrome.runtime.sendMessage pingexternally_connectable dans le manifestau chargement, ≤ 10 s

1. Marker DOM (préféré)

// content script, au plus tôt
document.documentElement.setAttribute("data-boostecom-extension", VERSION)

Deux noms sont acceptés, et ce n'est pas un oubli (app-shell/0610). data-boostecom-extension est le nom canonique ; data-contextiq est celui que le build déjà installé dans les navigateurs des opérateurs écrit. contextiq est l'ancien nom produit, purgé des surfaces utilisateur par intelligence/0584 — mais ce token-là n'est pas un libellé, c'est un contrat avec du code qu'on ne déploie pas.

La migration est donc en deux temps :

  1. fait — le lecteur accepte les deux (EXTENSION_PRESENCE_ATTRIBUTES). Rien ne change pour personne ;
  2. pas planifié — le nom legacy sort quand un build qui écrit le nouveau nom est publié et adopté. C'est une mesure d'adoption qui déclenche l'étape, pas une date. La source de l'extension écrit déjà le nouveau nom (section [Unreleased], sans bump de version : un bump signifie qu'un package a été uploadé) ; le 1.0.1 du Chrome Web Store, lui, écrit encore data-contextiq. Il ne manque donc plus de code, il manque une publication puis son adoption.

Ne pas « ranger » le nom legacy avant l'étape 2 : le retirer dé-détecte en silence toutes les extensions installées, et l'échec vit dans un navigateur qu'aucune CI ne voit. presence.test.ts épingle les deux noms pour que ce nettoyage échoue au lieu de passer.

N'importe quelle valeur non vide compte, donc la version peut y vivre. La plateforme observe l'attribut (MutationObserver), elle ne latche pas : retirer l'attribut repasse le badge en CTA.

Piège : un content script déclaré en activeTab n'est injecté que quand l'opérateur clique l'icône de l'extension. Pour que ce signal serve, il faut une content_scripts.matches sur https://*.boostecom.app/* (run_at: document_start) : pas seulement activeTab.

2. Handshake postMessage (aucun changement de manifest)

Le content script poste déjà boostecom-extension-element-select et boostecom-extension-page-capture dans la page pour livrer une sélection (consommés par features/ai/chat/runtime/use-chat-platform-bridge.ts). Tout message dont le type commence par boostecom-extension- vaut preuve de présence : un build qui ne sait rien faire d'autre est donc détecté dès la première sélection, sans une ligne de code en plus.

Pour flipper au chargement plutôt qu'à la première utilisation, répondre au probe que la plateforme diffuse au mount :

// content script
window.addEventListener("message", (e) => {
  if (e.source !== window) return
  if (e.data?.type !== "boostecom-platform-hello") return
  window.postMessage(
    { type: "boostecom-extension-hello", version: VERSION },
    window.location.origin,
  )
})
// …et une fois spontanément, au cas où on soit injecté après le probe
window.postMessage(
  { type: "boostecom-extension-hello", version: VERSION },
  window.location.origin,
)

Le probe existe parce que le content script est normalement injecté en document_start, bien avant l'hydratation React : son hello spontané arriverait avant que le listener de la plateforme existe. Les deux moitiés (spontané + réponse au probe) couvrent les deux ordres.

Côté plateforme : event.source === window et origine same-origin sont exigés, un iframe tiers ne doit pas pouvoir prétendre que l'opérateur a notre extension. Ce signal latche pour la durée de la page (il n'y a pas d'évènement « je m'en vais » à observer) ; un reload re-dérive tout, donc une désinstallation se voit à la navigation suivante.

Ne jamais namespacer un message plateforme en boostecom-extension- : le dialecte interne est boostecom: (deux-points), et le probe est boostecom-platform-hello. presence.test.ts épingle cette séparation.

3. Ping runtime (legacy)

chrome.runtime.sendMessage(EXTENSION_ID, { type: "ping" }) toutes les 10 s, réponse attendue { success: true }. N'atteint une extension que si son manifest liste cette origine dans externally_connectable.matches ; sans ça chrome.runtime n'est même pas défini sur la page et l'effet est un no-op. Conservé pour ne pas casser un build qui l'aurait, pas pour être le chemin principal.

EXTENSION_ID (src/config/platform.ts) retombe sur l'id publié quand NEXT_PUBLIC_BOOSTECOM_EXTENSION_ID est absent : l'id est public, le gater sur une env var n'achetait aucun secret et cassait la détection sur tous les déploiements.

Diagnostiquer en 10 secondes

Dans la console, sur https://www.boostecom.app :

document.documentElement.getAttribute("data-boostecom-extension")
  ?? document.documentElement.getAttribute("data-contextiq")   // signal 1
typeof chrome?.runtime?.sendMessage                       // signal 3 ("undefined" = pas d'externally_connectable)
window.addEventListener("message", (e) =>
  String(e.data?.type).startsWith("boostecom-extension-") && console.log("hello:", e.data))
window.postMessage({ type: "boostecom-platform-hello" }, location.origin)  // signal 2

Les trois muets = l'extension ne s'annonce pas, et le badge a raison d'afficher le CTA. Le correctif est alors dans Extensions/@BoostEcom, pas ici.

Les deux copies du badge

Le badge ne réutilise pas la même phrase dans les deux états (home.chromeExtension.value vs .valueInstalled, six locales) : avant installation le lecteur a besoin d'une raison de cliquer, après il a besoin de savoir quoi faire de ce qu'il a déjà. Servir « attachez plus de contexte » dans l'état installé faisait de cet état un cul-de- sac, il répétait une promesse déjà acceptée au lieu d'enseigner le geste (pointer un élément) qui l'encaisse.