Audit — pilier integrations
Cinquieme tour de la rotation (docs/team/roster.md, @codebase-auditor), apres data-platform, security-identity, billing et ai-platform. Lecture seule sur le code. Ce document et quatre items de backlog sont la sortie…
Cinquieme tour de la rotation (
docs/team/roster.md, @codebase-auditor), apresdata-platform,security-identity,billingetai-platform.Lecture seule sur le code. Ce document et quatre items de backlog sont la sortie complete.
Perimetre :
src/features/shopify/**,src/features/connectors/**,src/lib/mcp|fetch-providers/**,src/app/api/mcp|connectors|shopify|integrations|oauth|extension/**,src/app/oauth/**,src/app/api/webhooks/**,src/components/integrations/**,src/lib/frameworks/**,src/services/integration-events.ts,docs/architecture/mcp-oauth.md.Etat du depot :
origin/mainabad47586, 26 aout 2026.Motif du tour, ecrit la veille : un item
0008dort ici depuis le 19 aout avec une branche et une PR deja renseignees. Ce motif a tenu, mais pas de la facon prevue : voir le releve.
Ce qui a ete verifie
| Sonde | Resultat |
|---|---|
Outils MCP enregistres dans register-tools.ts | 12 — la claim de CLAUDE.md est exacte |
| … passant par le double gate (grant utilisateur × scope Shopify) | 12 sur 12 — verifiees une par une |
Garde d'ecriture sur le theme MAIN (allowLive) | presente et reelle (client.ts:299) |
| Endroits qui decident la version de l'API Shopify | 5 |
| … valeurs distinctes parmi eux | 2 (2025-10, 2026-04) |
shopifyAdminUrl() — le helper de la source unique declaree | 0 appelant |
| Constructions d'URL Admin API a la main | 19 |
Champs du type Connector absents de la table Prisma | 6 |
| Routes qui plantent des qu'un connecteur existe | 5 |
as unknown as dans prisma-provider.ts | 39, dont 5 sur Connector |
AccessDeniedError (invariant declare du pilier) | 0 occurrence dans le depot |
References a ~/.claude/rules/ (chemin de HOME) | 19, pour un repertoire absent du depot |
Alias /api/mcp/v1/[storeId] ↔ /api/mcp/[storeId] | fonctionnel, mais CLAUDE.md inverse les roles |
Le serveur MCP est la partie saine du pilier : le double gate est reel, documente, et applique aux douze outils sans exception. Le probleme est du cote connecteurs OAuth, et il n'est pas theorique.
Constat 1 — le type Connector decrit une forme qu'aucun code ne produit, et cinq routes plantent des qu'un connecteur existe
Severite : haute. → backlog/integrations/0059
Le fait
src/types/connectors.ts:88 declare :
export interface Connector {
id: string
storeID: string // la colonne s'appelle storeId
name: string // pas de colonne
config: ConnectorConfig // pas de colonne
lastSync?: ConnectorSyncResult // pas de colonne
dataSources: ConnectorDataSource[] // pas de colonne
…
}
Le modele Prisma (schema.prisma:603) porte
id, storeId, provider, status, accessToken, refreshToken, grantedScopes, metadata, createdAt, updatedAt. Six champs du type n'ont aucune colonne.
Et rien ne construit la forme manquante : decryptConnectorRow
(prisma-provider.ts:54)
etale la ligne plate et dechiffre deux champs. Il n'y a pas d'autre provider :
createDatabase() retourne createPrismaProvider(), point.
Donc connector.config vaut toujours undefined, et
connector.storeID aussi.
Reproduction
$ node -e '<decryptConnectorRow verbatim + le destructure de route.ts:28>'
cles rendues : id, storeId, provider, status, accessToken, refreshToken,
grantedScopes, metadata, createdAt, updatedAt
config present ? false
storeID present ? false
CRASH : TypeError: Cannot read properties of undefined (reading 'provider')
Ce qui casse
| Route | Ligne | Effet |
|---|---|---|
GET /api/connectors | route.ts:30 | config.provider sur undefined → 500 |
GET /api/connectors/[connectorId] | route.ts:58 | idem → 500 |
DELETE /api/connectors/[connectorId] | route.ts:131 puis :138 | getStoreAccess(userId, undefined) → 403 |
GET /api/connectors/[connectorId]/resources | resources/route.ts:48 | crash dans le .find() → 500 |
POST /api/tracking/scan | route.ts:104 | crash dans resolveCredentials |
Le DELETE est le plus visible : le bouton « Disconnect » de
oauth-connector.tsx:73
appelle cette route. getStoreAccess echoue fermee (if (!userId || !storeId) return null), donc ce n'est pas un trou de securite : c'est un
refus systematique. Un utilisateur ne peut pas deconnecter un
connecteur : il obtient toast.error("disconnectFailed"), toujours.
Pourquoi personne ne l'a vu
Trois raisons qui se cumulent, et c'est ce qui rend le constat interessant plutot qu'anecdotique :
- Les routes marchent tant que la liste est vide.
connectors.map(…)etconnectors.find(…)sur un tableau vide n'appellent jamais le callback. Un store sans connecteur recoit[]et un 200. Ca ne casse qu'au moment ou quelqu'un connecte quelque chose, c'est-a-dire au moment ou ca compte. - Le crash est avale. Les deux routes
connectorsenveloppent tout dans untry/catchquiconsole.erroret renvoie un 500 generique (« Failed to list connectors »). UneTypeErrorressemble a une panne de base. - Le typecheck est neutralise a la frontiere.
prisma-provider.tscontient 39as unknown as, dont 5 surConnector. Ce sont exactement les casts qui eteignent le controle qui aurait attrape ceci.pnpm typecheckest vert, et il a raison de l'etre : on lui a dit de se taire.
Ce n'est pas une decouverte, c'est une reprise non finie
Le 18 aout, il y a huit jours, le commit 20c905cb
« fix(connectors): repair OAuth token refresh crash + persist expiry »
a repare un appelant. Son en-tete, toujours en place dans
token-refresh.ts:6,
dit exactement ce constat :
« The exported
Connectortype still describes a legacy nestedconfig.credentialsenvelope that no column backs, so readingconnector.config.credentialsthrowsCannot read properties of undefinedat runtime — which is exactly what happened on every google / meta refresh. Until theConnectortype is reconciled repo-wide… »
Le fichier a documente la cause, s'est protege avec un as unknown as FlatConnectorRow, et a nomme le travail restant : « reconciled repo-wide ».
Aucun item de backlog n'a ete ouvert pour ce reste : verifie, aucun
fichier de backlog/ ne mentionne config.credentials. Cinq appelants
sont restes dans l'etat que l'incident venait de prouver mortel.
Constat 2 — la version de l'API Shopify est decidee a cinq endroits, deux d'entre eux ne disent pas la meme chose, et la source unique declaree perd
Severite : moyenne. → backlog/integrations/0060
docs/team/roster.md:203 et .claude/agents/integrations-engineer.md:29 :
« Version d'API lue depuis
src/features/shopify/sdk/version.ts, jamais en dur. »
Cinq endroits la decident :
| Endroit | Repli ecrit |
|---|---|
features/shopify/sdk/version.ts:12 — la source unique declaree | "2026-04" |
env/server.ts:553 — le schema typed | "2025-10" |
services/sell/revenue-sync.ts:61 | "2025-10" |
api/wizard/store/connect/route.ts:110 et :167 | "2025-10" |
features/connectors/providers/shopify-partners.ts:313 | "2025-10" |
Le repli de la source unique est inatteignable
version.ts ecrit serverEnv.SHOPIFY_API_VERSION || "2026-04". Mais
env/server.ts donne deja a cette cle un defaut non vide :
process.env.SHOPIFY_API_VERSION ?? "2025-10". serverEnv est un objet
litteral sans post-traitement (server.ts:456), donc la valeur est toujours
truthy et le || ne se declenche jamais.
$ node -e 'delete process.env.SHOPIFY_API_VERSION;
const e = process.env.SHOPIFY_API_VERSION ?? "2025-10";
console.log(e, "|", e || "2026-04")'
2025-10 | 2025-10
La plateforme tourne sur 2025-10. Le fichier qui se declare source de
verite annonce 2026-04, y compris dans son exemple de docstring
(→ https://…/admin/api/2026-04/graphql.json). Les deux seules valeurs
ecrites dans le depot se contredisent, et celle qui gagne est celle qui n'est
pas dans le fichier prevu pour ca.
Et le helper prevu pour ca n'est appele par personne
version.ts exporte shopifyAdminUrl(shopDomain, endpoint), ecrit,
documente, avec un exemple. Zero appelant. Les 19 constructions
d'URL Admin du depot l'ecrivent a la main, en template literal.
Ce n'est pas une remarque de style : le helper est le seul endroit ou la version pourrait etre imposee mecaniquement. Tant qu'il n'est appele nulle part, « jamais en dur » ne peut etre qu'une consigne de relecture.
La meme fiche d'agent ajoute : « Check for a newer version on every audit pass. » C'est une consigne adressee a ce document. Je ne peux pas la satisfaire honnetement : le calendrier de publication de Shopify n'est pas dans le depot, et je ne vais pas affirmer une date que je n'ai pas verifiee. Ce que le depot permet de dire, et qui suffit a ouvrir l'item : il porte deux versions et n'est d'accord avec lui-meme sur aucune.
Constat 3 — deux invariants du pilier nomment des choses introuvables
Severite : moyenne. → backlog/integrations/0061
Le pilier declare quatre invariants « Specifique » dans
docs/team/roster.md:200-205, repris dans le prompt systeme de l'agent
.claude/agents/integrations-engineer.md. Deux tiennent (source unique des
outils, garde allowLive). Deux nomment des choses qui n'existent pas.
AccessDeniedError — zero occurrence
.claude/agents/integrations-engineer.md:34— « The universal pass-through insrc/features/ai/tools/shopify-admin.tspropagatesAccessDeniedErrorverbatim. Do not swallow, do not re-scope. »
grep -rn "AccessDeniedError" src/ → rien. Aucun type, aucune classe,
aucune chaine.
Le comportement, lui, existe : c'est ce qui rend le constat precis
plutot qu'alarmiste. shopify-admin.ts:190 dit « Shopify returns userErrors
or a 403 if a scope is missing; surface that error verbatim », et
theme-tools.ts:56 classe le refus sur lower.includes("access denied").
L'invariant est reel ; c'est son nom qui est fictif.
La consequence n'est pas cosmetique quand la phrase vit dans un prompt
systeme : un agent a qui l'on ordonne de ne pas avaler AccessDeniedError
cherchera un symbole, ne le trouvera pas, et devra deviner si la regle porte
sur autre chose ou si le code l'a perdue.
Second detail, du meme ordre : le fichier nomme
(src/features/ai/tools/shopify-admin.ts) appartient a ai-platform
d'apres ownership.json, pas a ce pilier. L'agent recoit une consigne
permanente sur un fichier que pnpm fleet:scope lui interdit de modifier
seul.
La garde du theme live pointe vers un fichier du HOME
client.ts:291, dans le
bloc de doc de updateThemeFile, l'operation la plus dangereuse du pilier :
* Source of truth — business model:
* docs/business-model/risks-and-fortifications.md (C. Data integrity)
* ~/.claude/rules/shopify-theme-safety.md (developer guidance)
Le premier fichier existe. Le second est un chemin de HOME, et
.claude/rules/ n'existe pas dans le depot.
C'est exactement le defaut que CLAUDE.md:102 diagnostique, mot pour mot :
« Cette section renvoyait a
~/.claude/rules/registry-first.md, un fichier du HOME d'un poste : injoignable depuis un clone, la CI ou un agent, donc une regle imposee que personne ne pouvait lire. »
Le depot a pose le diagnostic, corrige une occurrence, et en a laisse 19. Le detail complet est au constat 4 : celui-ci ne porte que l'occurrence du pilier, et elle merite d'etre nommee separement parce que c'est la seule des 19 qui documente une garde sur l'ecriture en direct dans la boutique d'un client.
Constat 4 — dix-neuf renvois vers ~/.claude/rules/, pour un repertoire que le depot ne contient pas
Severite : moyenne. → backlog/inbox/0062
grep -rn "~/.claude/rules" sur tout le depot : 19 occurrences,
.claude/rules/ absent.
| Fichier cible du renvoi | Occurrences | Ou |
|---|---|---|
pcm-personality-adaptation.md | 14 | features/ai/personality/**, features/ai/prompts, features/ai/memory, api/account/communication, account/settings/communication |
shopify-theme-safety.md | 1 | features/shopify/mcp/shopify/client.ts:291 |
registry-first.md | 2 | CLAUDE.md:102 (le diagnostic, legitime) et docs/architecture/intelligence-pipeline.md:979 — un lien markdown […](~/.claude/rules/registry-first.md) qui rend et ne mene nulle part |
| generique | 2 | docs/canvas/tips-hacks.md:253,258 |
Neuf de ces renvois portent la mention « Source of truth » ou « Source de verite ». Le depot designe donc comme sa source de verite un fichier qu'aucun clone ne possede.
Et docs/canvas/tips-hacks.md:258 l'ecrit en toutes lettres :
«
~/.claude/rules/reste la source de vérité pour notre harness »
— ce qui contredit frontalement le paragraphe de CLAUDE.md cite plus haut.
Deux documents du meme depot donnent la consigne inverse sur le meme
repertoire.
Cet item est dans inbox/ et pas dans un pilier, volontairement :
14 occurrences sont a ai-platform, 1 a integrations, 1 a intelligence,
le reste en doc. Le trancher est un arbitrage de @conductor, pas une PR d'un
pilier. Ce que l'auditeur apporte, c'est le denombrement et la contradiction.
Releve, sans item
CLAUDE.md:141inverse l'alias et l'implementation. Il presentesrc/app/api/mcp/v1/[storeId]comme le serveur « interne » et[storeId]comme « alias non versionne garde pour les clients installes ». Le code dit l'inverse, et le dit bien :v1/[storeId]/route.tss'ouvre sur « Versioned alias of the per-store MCP endpoint » et tient en unexport {GET, POST, OPTIONS} from "../../[storeId]/route". Toute la substance (auth,build-server,register-tools,helpers,rate-limit,audit, le PRM.well-known, les deux fichiers de test) vit sous[storeId]/. Un mot a corriger dans un hot file : a faire passer avec le prochain changement qui toucheCLAUDE.md, pas dans une PR a lui seul.- Le couplage
runtime/maxDurationde l'alias est tenu a la main.v1/[storeId]/route.tsredeclarenodejs/30avec un commentaire qui dit pourquoi (Next refuse le re-export) et ce que ca coute : « They must stay in step with the values on the route this file aliases. » Rien ne le verifie. Les deux valeurs sont en phase aujourd'hui : verifie. C'est une derive latente, pas un defaut. - Quatre commentaires
// Always-on … no scope gatesurmontent une garde. Dansregister-tools.ts(l. 143, 227, 254, 271), chacun precede unif (gate(…)). Les douze outils sont gates ; le bloc de doctrine en tete du fichier l'explique correctement (« Every tool goes through here, including the ones with no Shopify requirement »). Ce sont les quatre commentaires locaux qui sont restes d'un etat anterieur. Dans le fichier qui est la frontiere de securite du pilier, un commentaire qui dit « pas de garde » au-dessus d'une garde est le genre de ligne qu'un relecteur saute. FILE_BASED_MCP_CONFIGn'est lu par personne. Declaree dansenv/server.ts:287,609, catalogue dansservices/env-audit.ts:598avec une description (« Points the MCP layer at a file-based config instead of the database ») et affichee a l'operateur sur/admin/operations/env-audit. Aucun lecteur ailleurs. La poser ne fait rien. Petit, mais c'est un bouton presente comme reel sur une page dont le seul objet est de dire ce qui est reel.src/lib/mcp/ne contient qu'un README, et ce README documente unmcpRegistryimporte de@boostecom/core/mcp(le monorepo abandonne en avril) et renvoie apackages/core/src/oauth/.mcpRegistry: zero occurrence dans le depot. Meme famille quebacklog/ai-platform/0056, a traiter avec lui. A ne pas confondre avecMcpConnector, qui est bien reel : modele Prisma, CRUD sous/api/mcp/connectors, consomme au runtime parfeatures/ai/mcp/runtime-client.tset le handler du chat. J'ai d'abord cru a une surface de configuration sans lecteur ; c'est faux, et la sonde qui me l'a fait croire ne cherchait que l'accesseur Prisma en minuscules.- Le verrou de
backlog/ne correspond a rien surorigin.backlog/README.mdetprotocol.md§1 posent que l'etat reel d'un claim est l'existence de la branchefleet/<pillar>/<id>-<slug>.originporte une seule branche (main) et zerofleet/*. Les sept items qui renseignent unbranch:citent tous une brancheclaude/…supprimee au merge : dontintegrations/0008, qui declarestatus: "ready"tout en portant une branche et la PR #510. Ce n'est pas un defaut du pilierintegrationset je ne l'instruis pas ici : c'est le modele operatoire qui a diverge de son ecriture, et ca appartient a @conductor. Note pour qu'il ne se perde pas.
Ce que cet audit n'a pas couvert
- Les webhooks (
src/app/api/webhooks/**, 8 fichiers) : la claim des 7 handlers est derivee parpnpm docs:claims, mais la verification HMAC, l'idempotence reelle et les trois endpoints GDPR obligatoires de Shopify n'ont ete ni lus ni exerces. C'est le plus gros trou de ce tour et le premier endroit ou reprendre. - Le flux OAuth 2.1 complet (
src/app/oauth/authorize/page.tsx, 480 lignes, +api/oauth/**) : lu pour les seulsaccess_denied; ni PKCE, ni DCR, ni la liaison d'audience n'ont ete sondes.docs/architecture/mcp-oauth.mdn'a pas ete confronte au code. - Le rate-limit MCP par tier (
rate-limit.ts) : non exerce. - Les six providers de connecteurs (
figma,google,klaviyo,meta,notion,shopify-partners) : seul le chemin de refresh google/meta a ete suivi. Les quatre autres n'ont pas ete ouverts. src/lib/fetch-providers/**etsrc/lib/frameworks/**— dans le perimetre, non ouverts.- L'item
0008lui-meme : je n'ai pas revalide ses deux blocages (transport stateless, version du SDK). Ils etaient verifies le 20 aout ; je n'ai pas verifie qu'ils le sont toujours.
Un auditeur qui ne borne pas sa portee laisse croire qu'il a tout vu.
Prochain pilier de la rotation : commerce-systems. Motif : c'est le
suivant dans l'ordre du roster, et c'est le pilier qui installe du code chez
le client (AEO, CRO, vitals, tracking, pixels), la meme classe de surface
que celle ou ce tour a trouve son constat 1, avec un rayon de souffle qui sort
de notre infrastructure.