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é parChromeExtensionBadgeviaConversationProvider.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 écritdata-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_campaignpost-install deboostecom_spyenboostecom_extension. « Spy » reste le nom du PRODUIT d'intelligence que l'extension expose, pas celui de l'extension.
| # | Signal | Ce que ça coûte à l'extension | Quand ça flippe le badge |
|---|---|---|---|
| 1 | Marker DOM data-boostecom-extension (ou data-contextiq, legacy) sur <html> | une ligne dans le content script | au chargement |
| 2 | postMessage boostecom-extension-* | rien — déjà envoyé | à la 1ʳᵉ utilisation, ou au chargement si le hello est implémenté |
| 3 | chrome.runtime.sendMessage ping | externally_connectable dans le manifest | au 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-extensionest le nom canonique ;data-contextiqest celui que le build déjà installé dans les navigateurs des opérateurs écrit.contextiqest l'ancien nom produit, purgé des surfaces utilisateur parintelligence/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 :
- fait — le lecteur accepte les deux (
EXTENSION_PRESENCE_ATTRIBUTES). Rien ne change pour personne ;- 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é) ; le1.0.1du Chrome Web Store, lui, écrit encoredata-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.