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 :
| Epoque | Etat |
|---|---|
| jusqu'a juin 2026 | trois 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 2026 | la 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 2026 | un dialogue par entite, ouvert sur place ; /onboarding est first-run only |
Ou vivent les deux wizards
| Entite | Composant | Ce qu'il ecrit |
|---|---|---|
| Boutique | features/ai/chat/runtime/generate-store-dialog.tsx | POST /api/stores, puis selon la branche /api/wizard/store/provision + /api/wizard/store/launch, ou le connecteur Shopify |
| Organisation | components/patterns/onboarding/organization-identity-form.tsx, rendu par le pas d'onboarding et par components/shared/tenant/create-organization-dialog.tsx | POST /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 :
- elle a teste
existingOrgseul — 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 ; - elle a porte
&& mode === "full", ce qui laissait?mode=organizationet?mode=storerouvrir 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 :
| Moment | Ce qui decide | Ou |
|---|---|---|
| Premiere inscription, depuis un lien | onboardingHasNothingFor(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 organisation | GET /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 isSafeReturnPath | api/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.
| Identifiant | Ce qu'on peut faire avec | Route |
|---|---|---|
Client ID + shpss_ (recommande) | tout : echange de token, enregistrement des webhooks, lecture du catalogue, backfill des commandes, warm-up Intelligence, jalons de setup | POST /api/integrations/shopify/custom-app |
shpat_ seul | lire la boutique a la demande | POST /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 :
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 ;POST /api/wizard/store/provision— claim atomique d'un dev store duDevStorePool(ouwaitlistedsi le pool est vide) ;POST /api/wizard/store/launch— persiste le spec surStoreContext.modules.onboardinget stampe la cible du transfert de propriete, d'ou le cronlaunch-tickpart.
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 :
| Ou | Duree de vie | |
|---|---|---|
| La progression pendant le scan | Redis (services/setup/scan-progress.ts) | le temps du scan, TTL 15 min |
| Le fait durable | le result de l'etape storefront_scan | pour toujours |
| Le transport | le 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 lestoreIdqui 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.