ArchitectureCreer une organisation, creer une boutique

Creer une organisation, creer une boutique

Une seule surface par entite, atteinte depuis partout. Ce document dit ou elle vit, qui l'ouvre, et pourquoi les deux identifiants Shopify ne sont pas interchangeables.

Une seule surface par entite, atteinte depuis partout. Ce document dit ou elle vit, qui l'ouvre, et pourquoi les deux identifiants Shopify ne sont pas interchangeables.

La regle

Un wizard par entite. L'endroit d'ou on l'ouvre ne change pas ce qu'on obtient.

Cette phrase a ete fausse trois fois de suite dans ce repo :

EpoqueEtat
jusqu'a juin 2026trois surfaces (wizard d'onboarding, modale « + », formulaire inline de l'apercu d'org) aux capacites disjointes : le wizard avait le selecteur de plan et pas la recherche d'entreprise, la modale l'inverse
aout 2026la modale est supprimee, les « + » pointent sur /onboarding?mode=organization|store — l'accord est obtenu en renvoyant un marchand etabli dans le wizard de premiere connexion
depuis septembre 2026un dialogue par entite, ouvert sur place ; /onboarding est first-run only

Ou vivent les deux wizards

EntiteComposantCe qu'il ecrit
Boutiquefeatures/ai/chat/runtime/generate-store-dialog.tsxPOST /api/stores, puis selon la branche /api/wizard/store/provision + /api/wizard/store/launch, ou le connecteur Shopify
Organisationcomponents/patterns/onboarding/organization-identity-form.tsx, rendu par le pas d'onboarding et par components/shared/tenant/create-organization-dialog.tsxPOST /api/organizations

Le wizard boutique vit toujours sous features/ai/chat/runtime/ pour une raison d'histoire, pas de conception : il y est ne. Il n'appartient plus au chat. Ses consommateurs, en entier :

  • features/ai/chat/runtime/ai-chat.tsx — menu « + » du composer ;
  • app/(minimal)/onboarding/_components/store-step.tsx — le pas « boutique » de l'onboarding est ce dialogue ;
  • components/shells/app-shell/shell-client.tsx — les deux entrees du header, le pied du store switcher, les deux etats vides ;
  • app/(dashboard)/[orgSlug]/_components/org-overview-client.tsx.

src/test/tenant-creation-single-surface.test.ts echoue si un quatrieme formulaire de creation reapparait, si un « + » repointe vers /onboarding?mode=, ou si le pas plan quitte la largeur de /pricing.

/onboarding est terminal

Le gate vit dans app/(minimal)/onboarding/page.tsx :

if (account?.onboarded && existingOrg) {
  redirect(`/${existingOrg.slug}`)
}

User.onboarded est ecrit par /api/auth/onboarding-complete, a la fin, et seulement une fois qu'une organisation existe. Deux erreurs ont vecu sur cette ligne :

  1. elle a teste existingOrg seul — or une organisation est l'etape deux sur quatre, donc creer son org puis recharger ejectait le marchand vers un dashboard sans boutique et sans choix de visibilite ;
  2. elle a porte && mode === "full", ce qui laissait ?mode=organization et ?mode=store rouvrir le wizard de premiere connexion sur un compte fini. C'est ce que les « + » du dashboard liaient.

Les quatre pas sont donc fixes : compte → organisation (+ plan) → boutique → visibilite. Il n'y a plus de « mode ».

Qui n'entre jamais dans le tunnel

Le tunnel demande une entreprise puis une boutique. C'est juste pour un marchand et faux pour tout le monde d'autre que la plateforme inscrit, et le schema le dit deja : un affilie est AffiliateCode.affiliateId (un User), un vendeur est MarketplaceListing.sellerId (un User), un partenaire recoit une fiche d'annuaire et un acces delegue chez son client (ADR 0014), jamais une organisation. La regle de produit tient en une phrase : ils ne doivent jamais entrer la ou il n'y a pas de tableau de bord pour eux (app-shell/2742).

Deux mecanismes, parce qu'il y a deux moments :

MomentCe qui decideOu
Premiere inscription, depuis un lienonboardingHasNothingFor(returnTo) : une liste ECRITE de prefixes (/invite, /oauth/authorize, /account/settings/referrals, /sell, /collab) pour lesquels /auth honore la destination au lieu d'ouvrir le tunnel(minimal)/auth/skips-onboarding.ts
Retour d'un compte sans organisationGET /api/auth/onboarding-status repond un landing (/sell/dashboard s'il a une fiche, /account/settings/referrals s'il a un code) que /auth et le proxy consomment apres defaultOrgSlug, revalide par isSafeReturnPathapi/auth/onboarding-status/route.ts

Ce que ca ne fait pas, et qui est le piege : rien ne fabrique d'organisation pour faire basculer needsOnboarding. Le fait reste vrai (ce compte ne possede rien), seule la destination change. Une organisation vide aurait ete le meme probleme sous un autre nom, et elle aurait rendu un tableau de bord a quelqu'un qui n'en a pas.

Les trois pages publiques qui recrutent ces publics lient chacune leur porte (/network/affiliate → /account/settings/referrals, /sell → /sell/dashboard, /network/partners → /sell/dashboard/new pour la fiche d'annuaire). La page affilie disait « dans vos parametres de compte » et ne liait rien : un anonyme n'avait que le tunnel pour y arriver. La candidature partenaire reste une conversation (NetworkApplication, sans compte), et l'equipe interne n'est pas dans ce perimetre : elle passe par un mandat de plateforme et /ops (security-identity/0605).

Un compte peut devenir marchand plus tard sans rien retraverser : la liste ne concerne que la destination apres /auth, et /onboarding reste joignable pour creer l'organisation le jour ou elle sert.

Garde : src/test/every-public-enters-by-its-own-door.test.ts (chaque porte est une route reelle hors [orgSlug], chaque page publique lie la sienne, landing est consomme apres l'org et revalide, aucun de ces fichiers ne cree d'organisation).

Connecter ou creer, aux deux pas

Boutique — les deux cartes du pas ouvrent le meme dialogue en pinnant sa branche (initialMode). Le dialogue rend son propre ecran de choix quand personne ne pinne rien (chat, dashboard).

Organisation — invitation-join-panel.tsx liste les invitations en attente adressees a l'e-mail de la session, au- dessus du formulaire de creation. Il ne rend rien quand il n'y a rien en attente : un encart « vous n'avez aucune invitation » sur chaque inscription serait du bruit sur le chemin majoritaire.

Accepter depuis l'app passe par POST /api/organizations/invitations, et n'est pas plus faible que le lien e-mail :

  • le jeton prouve qu'on a recu l'e-mail a l'adresse invitee ;
  • la plateforme s'authentifie par magic link Resend ou OTP e-mail — pas de mot de passe, pas de provider social — donc une session prouve le controle de cette boite, et le prouve plus recemment ;
  • les deux routes appliquent la meme regle : e-mail de session == e-mail de l'invitation. La liste est scopee dessus, et le POST la re-verifie sur la ligne, jamais sur l'id recu.

La redemption elle-meme est redeemInvitation() dans services/organizations/invitations.ts, partagee par les deux routes : appartenance + acceptedAt + AuditLog + invalidation du cache /api/me. La moitie de cela sur un chemin et pas sur l'autre, c'est ainsi qu'un journal d'audit se troue sans que personne le voie.

Rejoindre une organisation termine l'onboarding : elle existe, quelqu'un d'autre l'a configuree, ses boutiques sont connectees, son plan est choisi. Il ne reste rien dans ces quatre pas que ce marchand doive repondre.

Les deux identifiants Shopify, et pourquoi les deux restent

Le pas « connecter » demande deux choses, et ce n'est pas de l'indecision.

IdentifiantCe qu'on peut faire avecRoute
Client ID + shpss_ (recommande)tout : echange de token, enregistrement des webhooks, lecture du catalogue, backfill des commandes, warm-up Intelligence, jalons de setupPOST /api/integrations/shopify/custom-app
shpat_ seullire la boutique a la demandePOST /api/wizard/store/connect

La difference n'est pas de degre. Shopify signe chaque livraison de webhook avec le secret de l'app qui a cree l'abonnement (verify-hmac.ts). Sans le shpss_, il n'existe aucun moyen de distinguer une livraison authentique d'une contrefaite ; enregistrer des webhooks produirait un flux de 401, pas des donnees. Une boutique connectee par token seul est donc lue quand on le demande, et ne se synchronise pas toute seule.

Le pas le dit au marchand plutot que de le supposer. Supprimer l'un des deux chemins au nom de « une seule voie » aurait retire silencieusement la synchronisation vivante de l'onboarding — c'est exactement la regression que ce document existe pour empecher.

Reste ouvert : les deux mecanismes coexistent parce que choisir un seul grant Shopify est une decision produit, pas un nettoyage d'UI. Voir backlog/_archive/app-shell/0146.

Ce que le pas « boutique » declenche vraiment

Creer (branche create) enchaine, dans cet ordre :

  1. POST /api/stores — quota (requireQuota : ne refuse que Free au-dela de sa boutique d'essai, ADR 0037), slug resolu serveur, invalidation du cache d'org, plan de setup ;
  2. POST /api/wizard/store/provision — claim atomique d'un dev store du DevStorePool (ou waitlisted si le pool est vide) ;
  3. POST /api/wizard/store/launch — persiste le spec sur StoreContext.modules.onboarding et stampe la cible du transfert de propriete, d'ou le cron launch-tick part.

Voir docs/launch-playbook.md et docs/architecture/store-provisioning.md.

Ce que le marchand VOIT pendant que ca tourne

Le scan ne demarre pas a l'etape « scan » du tunnel : il part a la CONNEXION Shopify (triggerStoreIndex, dans api/wizard/store/connect), donc le travail a deja commence quand le marchand arrive devant l'ecran qui le montre. C'est le bon ordre, et c'est aussi ce qui rend l'ecran utile : il decrit quelque chose qui tourne, il ne le declenche pas.

Ce qui manquait est la GRANULARITE. storefront_scan est une etape du plan, et le scan qu'elle couvre lance entre quinze et trente sondes a la file : identite, theme, catalogue, marches, apps, pixels, analytics, avis, reseaux sociaux, signaux de commerce, SEO, compatibilite agents… Le marchand voyait donc UNE ligne « Analyse de la vitrine… » pendant une demi-minute (app-shell/2794).

Le detail sonde par sonde passe par le chemin qui existait deja :

OuDuree de vie
La progression pendant le scanRedis (services/setup/scan-progress.ts)le temps du scan, TTL 15 min
Le fait durablele result de l'etape storefront_scanpour toujours
Le transportle sondage que le tunnel fait deja sur /api/stores/[id]/setup—

Pas de SSE, et c'est un choix. Le tunnel interroge deja cet endpoint toutes les trois secondes et une sonde dure d'une a plusieurs secondes : la granularite manquait, le canal non. Un second transport aurait donne une seconde source de verite sur la meme question, et deux endroits ou chercher le jour ou l'ecran se fige.

Trois proprietes tiennent l'honnetete de cet ecran, et src/test/the-scan-shows-what-it-really-does.test.ts les mesure :

  • chaque ligne est une sonde qui a REELLEMENT fini. Le moteur appelle son rappel apres avoir pousse le resultat, jamais sur un minuteur. Une barre qui avance toute seule pendant que rien ne tourne est exactement ce que ce lot remplace ;
  • la progression ne ralentit ni n'interrompt le scan. Le rappel n'est pas attendu et son exception est avalee : c'est un confort, le fait durable est ecrit ailleurs ;
  • le moteur ne connait pas le tunnel. Le rappel est INJECTE (ShopifyDeepScanInput.onProbeDone), donc le cron de decouverte, qui scanne des boutiques tierces sans que personne regarde, n'ecrit aucune progression. C'est le storeId qui distingue les deux.

Une sonde sans libelle traduit n'est pas affichee du tout : le catalogue de sondes appartient au moteur d'intelligence et bouge sans passer par les six catalogues, or montrer social_handle_extractor a un marchand est pire que de ne rien montrer. La garde derive la liste du moteur et exige les six locales, dans les deux sens — une sonde sans libelle echoue, un libelle sans sonde aussi.

Qui possede quoi apres une creation

POST /api/organizations force toujours plan: "free". Un palier paye n'est ecrit que par le webhook Stripe, apres un paiement reel. C'est pourquoi le dialogue du dashboard ne porte pas de selecteur de plan : y proposer un prix serait un checkout qui n'a pas lieu. Le pas d'onboarding en porte un parce qu'il enchaine sur Stripe Checkout a la fin du wizard (checkout-intent), et il rend les cartes de /pricing, a la largeur de /pricing.