Audits · août 2026Audit — pilier integrations

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), apres data-platform, security-identity, billing et ai-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/main a bad47586, 26 aout 2026.

Motif du tour, ecrit la veille : un item 0008 dort 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

SondeResultat
Outils MCP enregistres dans register-tools.ts12 — 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 Shopify5
… valeurs distinctes parmi eux2 (2025-10, 2026-04)
shopifyAdminUrl() — le helper de la source unique declaree0 appelant
Constructions d'URL Admin API a la main19
Champs du type Connector absents de la table Prisma6
Routes qui plantent des qu'un connecteur existe5
as unknown as dans prisma-provider.ts39, 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

RouteLigneEffet
GET /api/connectorsroute.ts:30config.provider sur undefined → 500
GET /api/connectors/[connectorId]route.ts:58idem → 500
DELETE /api/connectors/[connectorId]route.ts:131 puis :138getStoreAccess(userId, undefined) → 403
GET /api/connectors/[connectorId]/resourcesresources/route.ts:48crash dans le .find() → 500
POST /api/tracking/scanroute.ts:104crash 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 :

  1. Les routes marchent tant que la liste est vide. connectors.map(…) et connectors.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.
  2. Le crash est avale. Les deux routes connectors enveloppent tout dans un try/catch qui console.error et renvoie un 500 generique (« Failed to list connectors »). Une TypeError ressemble a une panne de base.
  3. Le typecheck est neutralise a la frontiere. prisma-provider.ts contient 39 as unknown as, dont 5 sur Connector. Ce sont exactement les casts qui eteignent le controle qui aurait attrape ceci. pnpm typecheck est 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 Connector type still describes a legacy nested config.credentials envelope that no column backs, so reading connector.config.credentials throws Cannot read properties of undefined at runtime — which is exactly what happened on every google / meta refresh. Until the Connector type 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 :

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 in src/features/ai/tools/shopify-admin.ts propagates AccessDeniedError verbatim. 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 renvoiOccurrencesOu
pcm-personality-adaptation.md14features/ai/personality/**, features/ai/prompts, features/ai/memory, api/account/communication, account/settings/communication
shopify-theme-safety.md1features/shopify/mcp/shopify/client.ts:291
registry-first.md2CLAUDE.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
generique2docs/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:141 inverse l'alias et l'implementation. Il presente src/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.ts s'ouvre sur « Versioned alias of the per-store MCP endpoint » et tient en un export &#123;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 touche CLAUDE.md, pas dans une PR a lui seul.
  • Le couplage runtime / maxDuration de l'alias est tenu a la main. v1/[storeId]/route.ts redeclare nodejs / 30 avec 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 gate surmontent une garde. Dans register-tools.ts (l. 143, 227, 254, 271), chacun precede un if (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_CONFIG n'est lu par personne. Declaree dans env/server.ts:287,609, catalogue dans services/env-audit.ts:598 avec 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 un mcpRegistry importe de @boostecom/core/mcp (le monorepo abandonne en avril) et renvoie a packages/core/src/oauth/. mcpRegistry : zero occurrence dans le depot. Meme famille que backlog/ai-platform/0056, a traiter avec lui. A ne pas confondre avec McpConnector, qui est bien reel : modele Prisma, CRUD sous /api/mcp/connectors, consomme au runtime par features/ai/mcp/runtime-client.ts et 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 sur origin. backlog/README.md et protocol.md §1 posent que l'etat reel d'un claim est l'existence de la branche fleet/<pillar>/<id>-<slug>. origin porte une seule branche (main) et zero fleet/*. Les sept items qui renseignent un branch: citent tous une branche claude/… supprimee au merge : dont integrations/0008, qui declare status: "ready" tout en portant une branche et la PR #510. Ce n'est pas un defaut du pilier integrations et 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 par pnpm 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 seuls access_denied ; ni PKCE, ni DCR, ni la liaison d'audience n'ont ete sondes. docs/architecture/mcp-oauth.md n'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/** et src/lib/frameworks/** — dans le perimetre, non ouverts.
  • L'item 0008 lui-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.