Audit — pilier commerce-systems
Sixieme tour de la rotation (docs/team/roster.md, @codebase-auditor), apres data-platform, security-identity, billing, ai-platform et integrations. Lecture seule sur le code. Ce document et quatre items de backlog sont…
Sixieme tour de la rotation (
docs/team/roster.md, @codebase-auditor), apresdata-platform,security-identity,billing,ai-platformetintegrations.Lecture seule sur le code. Ce document et quatre items de backlog sont la sortie complete.
Perimetre :
src/features/aeo|cro|vitals|tracking|pixels|systems|store-runtime|commerce|aov|action-plan|reports|notes/**,src/app/api/tracking|pixels|vitals|preview/**,src/app/(minimal)/scan|setup/**,src/app/(dashboard)/[orgSlug]/[storeSlug]/systems/**,src/services/preview-meta/**.Etat du depot :
origin/mainaa462f8fb, 26 aout 2026.Motif du tour, ecrit la veille : c'est le pilier qui installe du code chez le client — le seul dont le rayon de souffle sort de notre infrastructure. C'est bien la qu'est le constat le plus lourd, mais pas pour la raison attendue.
Ce qui a ete verifie
| Sonde | Resultat |
|---|---|
Le scan tracking public reste dans (minimal) | oui — invariant respecte |
| Repertoires du perimetre absents du disque | 0 sur 12 |
Imports croises features/X → features/Y (tout le depot) | 31, sur 11 aretes |
| … dont un cycle de valeur | oui — ai ↔ store-runtime |
| Garde (CI, eslint, script) verifiant l'autonomie des features | aucune |
| Verification HMAC des beacons annoncee par la doc de la route | oui |
| … reellement executee | non |
Appelants de verifySignature hors tests | 0 |
Tests verts sur verifySignature | 6 |
originMatchesStoreDomain : copies dans le depot | 2 |
| … comportement identique (matrice de 15 cas) | oui, formatage different |
Champ enregistrant qu'un Store.domain a ete verifie | aucun |
Sous-systemes dependant de VITALS_BEACON_SECRET | 4, dont 1 nomme d'apres lui |
Le pilier est le mieux teste de ceux vus jusqu'ici : vitals porte 8 fichiers
de test sur 29, la derivation de sous-cle par store est correcte, le hachage
de sessionId empeche deliberement la correlation d'un visiteur entre deux
boutiques, et le modele de confiance retenu pour les beacons est defendable
et argumente. Le probleme n'est pas la conception. C'est que trois
documents sur quatre decrivent une autre conception que celle qui tourne.
Constat 1 — l'invariant d'autonomie des features a onze violations, un cycle, et aucune garde
Severite : moyenne. → backlog/commerce-systems/0064
docs/team/roster.md:221 :
« Chaque systeme reste autonome : pas de dependance croisee entre
features/*sans passer parsrc/lib/ousrc/services/. »
Denombrement sur les 18 repertoires de src/features/ : 31 imports
croises, 11 aretes.
| Arete | Imports | Dont valeur |
|---|---|---|
ai → store-runtime | 13 | 9 |
ai → shopify | 5 | — |
ai → connectors | 3 | — |
pixels → vitals | 2 | 2 |
store-runtime → ai | 2 | 1 |
action-plan → aov / cro / vitals | 3 | 2 |
ai → cro / action-plan | 2 | 2 |
tracking → shopify | 1 | 1 |
Aucune garde ne verifie cet invariant, verifie : pas de regle eslint
import/no-cycle ou boundaries, pas de madge ni de dependency-cruiser
dans package.json, rien dans scripts/. pnpm fleet:scope verifie a quel
pilier appartient un diff, pas qui importe qui.
Le cycle est reel, et il est en valeur des deux cotes
ai/conversation/execute-signals.ts:27 → store-runtime/knowledge/indexer
store-runtime/knowledge/indexer.ts:22 → ai/memory/rag
Ce ne sont pas des imports de type. Et le detail le rend concret : dans
execute-signals.ts, les lignes 26 et 27 sont voisines —
import { ingestKnowledgeSource } from "@/features/ai/memory/rag"
import { indexShopifyEntity } from "@/features/store-runtime/knowledge/indexer"
— alors que indexShopifyEntity est un habillage de
ingestKnowledgeSource, importe depuis le meme module. ai traverse
store-runtime pour rejoindre sa propre primitive. L'habillage a une valeur
reelle (il ajoute les metadonnees de kind), c'est son adresse qui cree
le cycle : src/lib/ ou src/services/, ce que l'invariant prescrit, le
dissoudrait.
Une arete que l'invariant, applique a la lettre, casserait
tracking/scan/clients/shopify-admin.ts:3 importe
@/features/shopify/sdk/version — la constante de version de l'API Shopify.
C'est une violation au sens strict. C'est aussi exactement ce qu'il faut
faire : l'audit integrations de la veille a ouvert
backlog/integrations/0060
parce que cette version est decidee a cinq endroits au lieu d'un.
Appliquer l'invariant a la lettre ici pousserait ce fichier a dupliquer la constante, c'est-a-dire a produire le defaut que l'item d'a cote demande de corriger. Un invariant qui, applique, cree la dette d'un autre pilier a besoin de son exception ecrite, pas d'une garde plus stricte.
Constat 2 — trois documents annoncent une verification HMAC que le code ne fait pas
Severite : haute (le risque est documentaire, pas exploitable, voir plus bas).
→ backlog/commerce-systems/0065
POST /api/vitals/ingest est l'entree publique des beacons RUM. Sa doc
d'en-tete (route.ts:20-23) :
« Trust model : The collector embeds a per-store HMAC sub-secret at extension publish time. Every beacon is signed; the route verifies the sig in constant time before touching the DB. »
signature.ts:5-9 dit la
meme chose :
« The server re-derives the same subkey and checks the signature in constant time before accepting the row. »
Et beacon-handler.ts:85,
en commentaire inline :
« Parse JSON before signature verification: if the body isn't valid JSON the signature check is meaningless. »
Aucune verification de signature n'a lieu. beacon-handler.ts:55
n'importe qu'une seule chose de signature.ts :
import { hashSessionId } from "./signature"
verifySignature et signPayload (ceux de vitals) n'ont aucun appelant
en production : verifie sur tout src/. Ils sont exportes publiquement par
features/vitals/index.ts:39 et couverts par six tests verts dans
signature.test.ts, ce qui acheve de les faire passer pour vivants.
Le modele reel est different, et il est correct
Le quatrieme document — l'en-tete de beacon-handler.ts, dit la verite, et
argumente :
« Trust model — Origin-based verification. […] this is the same model Plausible / Datafast use, and is the only honest option for a script that's by definition public (any client-embedded HMAC secret can be extracted by an attacker — it would be security theatre). For server-to-server relays (not yet wired) we keep the HMAC helpers in
signature.ts. »
C'est juste. Un secret embarque dans du JS de storefront public est
extractible ; le controle d'Origin est ce que font les concurrents cites.
pixels/handler.ts documente le meme modele, correctement.
Le constat n'est donc pas « le modele est faux ». Il est : trois textes sur quatre, dont celui de la route elle-meme, promettent une garantie plus forte que celle qui existe, et les fonctions qui l'implementeraient dorment a cote, testees, exportees, appelees par personne.
Le cout est precis. Un relecteur, un auditeur de securite ou un operateur qui
ouvre le fichier de la route conclut que la forge de beacons est empechee
cryptographiquement. Elle ne l'est pas : Origin n'est infalsifiable que
depuis un navigateur. Depuis curl, il s'ecrit. Ce qui protege
reellement, c'est que forger demande de connaitre le storeId et le
domaine de la boutique : un cout, pas un mur. C'est un risque accepte et bien
raisonne dans beacon-handler.ts ; c'est un risque invisible pour qui lit
les trois autres.
Constat 3 — la verification de domaine ne persiste rien, et deux handlers parlent du « verified domain »
Severite : moyenne. → backlog/commerce-systems/0066
Les deux handlers d'ingestion fondent leur controle d'acces sur
store.domain, en le qualifiant de verifie :
beacon-handler.ts:13— « We require Origin to match the store's verifieddomain. »beacon-handler.ts:227— « True when the Origin header points to the store's verified domain. »
Le mot revient six fois dans ce seul fichier (l. 13, 24, 99, 112, 227, 254).
POST /api/stores/[storeId]/verify-domain existe et fait un vrai controle
DNS TXT. Mais en cas de succes :
if (verified) {
await prisma.store.update({
where: { id: storeId },
data: { domain: store.domain }, // ← reecrit la valeur par elle-meme
})
}
Le resultat part dans la reponse JSON et n'est ecrit nulle part. Verifie :
le modele Store n'a aucun champ de verification (grep -i verif sur le
bloc du modele : rien ; les trois verifiedAt du schema appartiennent a
KycVerification, ContentRender et IntelligenceOptOut).
Et POST /api/stores (route.ts:61) ecrit domain directement depuis
l'entree utilisateur, sans passer par quoi que ce soit.
Donc Store.domain est ce que le marchand a tape. L'unicite globale
(@unique) empeche deux organisations de revendiquer le meme domaine : c'est
la vraie protection, et elle tient. Mais le mot « verified », repete dans le
fichier qui decide d'accepter ou non un beacon, ne designe aucun etat que le
systeme possede.
Constat 4 — VITALS_BEACON_SECRET porte quatre usages sans rapport, et sa rotation reecrit les identites de clients
Severite : moyenne. → backlog/commerce-systems/0067
| Consommateur | Usage |
|---|---|
features/shopify/ingester/lib/email-hash.ts:26 | HMAC des emails clients — l'identite « meme client entre deux commandes » |
app/api/sidekick/data/route.ts:36 | secret de signature Sidekick |
app/api/sidekick/action/route.ts:31 | idem |
features/vitals/collector/signature.ts:28 | le seul usage que son nom decrit — et c'est le chemin mort du constat 2 |
Les trois premiers ecrivent serverEnv.VITALS_BEACON_SECRET || serverEnv.AUTH_SECRET.
Consequence concrete : faire tourner cette cle (le geste normal apres une
fuite, et ce que signature.ts promet explicitement (« We can rotate a single
store's subkey… »)) change tous les hash d'emails clients. Les lignes
deja en base gardent l'ancien hash, les nouvelles portent le nouveau, et la
jointure « meme client » se scinde silencieusement en deux. Aucune erreur,
aucune alerte : juste des clients qui deviennent deux clients.
L'entree du catalogue operateur
(services/env-audit.ts:487) ne le dit
pas :
« HMAC-SHA256 secret for web-vitals beacons from theme extension. »
Elle decrit le seul usage qui n'a pas lieu, et tait les trois qui ont lieu.
C'est cette entree que /admin/operations/env-audit presente a l'operateur
qui se demande s'il peut y toucher.
Le commentaire de email-hash.ts:10 assume le partage, « the same secret is
reused for forward secrecy », mais la formule se contredit : reutiliser un
secret entre sous-systemes est le contraire de la forward secrecy. Ce n'est
pas le fond du constat, c'est le signe qu'il n'a pas ete relu depuis.
Releve, sans item
- Le commentaire dit « exactly one subdomain level », le code n'en limite
aucun.
originMatchesStoreDomaindocumente « Allow exactly one subdomain level (e.g. shop.boutique.com) » et implementecleanOrigin.endsWith(\.${cleanDomain}`), qui accepte n'importe quelle profondeur. Verifie a l'execution :a.b.boutique.comcontre le domaineboutique.com→true`. Ce n'est pas un defaut de securite — tout sous-domaine d'un domaine verifie est sous controle du marchand, mais le commentaire decrit une borne qui n'existe pas. originMatchesStoreDomainexiste en deux copies,vitals/collector/beacon-handler.tsetpixels/handler.ts, au comportement identique, verifie sur une matrice de 15 entrees (domaines nus,www., sous-domaines,.myshopify.com, suffixes trompeurs commeboutique.com.evil.io, URL invalide, domaine nul, casse melangee). Le formatage differe (chainage multi-ligne vs une ligne, accolades presentes ou non), donc ungrepsur la chaine exacte ne les apparie pas.sanitiseCountryetsanitiseUserAgentsont dupliquees a l'identique. C'est la consequence mecanique du constat 1 : faute de foyer partage,pixelsimporte devitalsce qu'il peut (hashSessionId,normalisePathnameToTemplate, deux violations de l'invariant) et copie ce qu'il ne peut pas. Le morceau copie est le controle de securite. A traiter avec l'item 0064./api/vitals/ingestdocumente cinq issues et pas debad_signature. Coherent avec le code ; incoherent avec sa propre section « Trust model » trois lignes plus haut. Se corrige avec l'item 0065.features/reportsetfeatures/notestiennent en unactions.tschacun (76 et 122 lignes, 4 imports au total). Petits, mais reels et utilises : a ne pas confondre avec les repertoires vides du constat 1 de l'auditai-platform. Verifie fichier par fichier, pas au compteur.
Ce que cet audit n'a pas couvert
src/features/tracking— 97 fichiers, 10 922 lignes, la plus grosse surface du pilier. Seulscan/clients/shopify-admin.tsa ete ouvert, pour la sonde d'imports croises. Le scan lui-meme n'a pas ete audite : ni la surface SSRF (il va chercher des domaines fournis par l'utilisateur), ni le rate-limit, niapi/tracking/artifacts/[scanId]/[name]qui sert des fichiers par nom. C'est le plus gros trou de ce tour.aeo,cro,aov,commerce,systems,action-plan: non ouverts.- Les algorithmes de
vitals(regression-detector,third-party-blame,budget-check,template-impact) : non exerces ; aucune verification que leurs sorties correspondent a ce que le dashboard en dit. api/preview/**etservices/preview-meta/**— dans le perimetre, non ouverts.- La chaine reelle du Theme App Extension,
storefront-bundle.tsa ete vu, mais aucun bundle produit n'a ete inspecte : je ne peux pas dire ce que le JS livre chez le marchand contient reellement, seulement ce que le code qui le fabrique dit vouloir y mettre.
Un auditeur qui ne borne pas sa portee laisse croire qu'il a tout vu.
Prochain pilier de la rotation : intelligence. Motif : il porte trois
invariants ecrits en dur dans le roster, noindex,nofollow sur les pages
par-store, liens sortants sans UTM et en rel="nofollow", et « pas de claim
d'edge sans ledger PredictionAccuracy ». C'est le pilier dont les invariants
sont les plus mecaniquement verifiables du depot, et les deux derniers tours
ont montre ou se loge l'ecart : sur integrations, deux des quatre invariants
declares nommaient des choses introuvables ; ici, celui qui restait n'avait
aucune garde et onze violations. Un invariant ecrit est une promesse
verifiable, c'est la seule raison qui rende ces tours rentables.