La galaxie
ORBITE I · CONSTELLATION

Builders

Livrer du produit

Transformer une idée en quelque chose qui charge, qui a de l'allure et qui se déploie. Astro, composants, multi-tenant, white-label et i18n. Le métier d'Espejo et des sites de XNLAB.

Alimente EspejoXNLAB
6 modules · 6 leçons
MODULES DE LA CONSTELLATION Ouvre chaque module pour le cours complet
BD-01

Astro & Vite qui chargent vite

leçon

Monter et comprendre un site Astro qui génère du HTML statique par route et par locale, ainsi qu'un projet Vite SPA, en sachant exactement quel JS arrive au navigateur et pourquoi le chargement est rapide.

90 % de la lenteur du web moderne, c'est du JavaScript qu'il n'était pas nécessaire d'envoyer. Astro et Vite te donnent de la vitesse par défaut, mais seulement si tu comprends où chaque chose s'exécute : build-time vs runtime. Les confondre est la cause racine des sites statiques qui se mettent soudain à peser 400 Ko de JS.

LA LEÇON

Commence par la distinction qui régit tout : en Astro, le code d'un composant .astro s'exécute au BUILD (sur ta machine ou en CI), pas dans le navigateur. Le résultat, c'est du HTML plat. MOONKEY est exactement ça : `astro.config.mjs` déclare `i18n` avec `fallbackType: 'rewrite'` et n'utilise PAS de SSR. Quand tu lances `npm run build`, Astro parcourt `src/pages/`, exécute chaque .astro une fois par locale et écrit du HTML dans `dist/`. Il n'y a pas de serveur en production : Cloudflare Pages sert des fichiers. C'est ce qui fait que `moonkeylab.pages.dev` charge en quelques millisecondes.

Crée le squelette à partir de zéro pour le voir sans magie : `npm create astro@latest mi-sitio -- --template minimal --no-install`, puis `cd mi-sitio && npm install && npm run dev`. Ouvre `http://localhost:4321`. Édite `src/pages/index.astro` et observe qu'un `const hoy = new Date()` affiché avec `{hoy}` se fige au moment du build quand tu fais `npm run build`. C'est ça le déclic : le JS du frontmatter (entre les `---`) tourne une seule fois, pas à chaque visite.

Maintenant le contrôle fin : les îlots. Par défaut, un composant de framework (React/Svelte) dans Astro est rendu en HTML statique SANS son JS. Il ne s'hydrate que si tu lui mets une directive `client:*`. `client:load` hydrate au chargement, `client:visible` quand il entre dans le viewport, `client:idle` quand le thread est libre. Règle de métier : commence SANS directive. N'ajoute `client:visible` que quand le composant a BESOIN d'une vraie interactivité. Dans MOONKEY, l'`IntersectionObserver` du reveal vit dans `Layout.astro` sous forme de `<script>` global, pas comme un îlot par page — c'est la bonne décision : un script de 15 lignes plutôt que d'hydrater un framework entier.

Vite est le moteur sous Astro, et aussi celui d'Espejo (qui est une SPA React pure : `vite.config.ts`, `@vitejs/plugin-react`). La différence : Espejo, lui, envoie bien React au navigateur parce qu'il a besoin d'un état interactif en direct (lectures caméra avec MediaPipe, navigation avec react-router). C'est ça la vraie ligne de partage du métier : le contenu est-il majoritairement de la lecture (site de XNLAB, MOONKEY) ou est-ce une app à état dense (Espejo) ? Le premier appelle Astro/SSG ; le second, Vite+SPA. Ne mélange pas les religions : ne fourre pas une SPA entière dans Astro quand 95 % est du contenu.

Mesure, ne donne pas d'avis. Lance `npm run build` et regarde la taille de `dist/`. Dans MOONKEY, un build de page de contenu devrait avoir ~0 Ko de JS de page (seulement le script de reveal partagé). Ouvre DevTools → Network, recharge avec le cache vide et filtre par JS : si tu vois un bundle de framework sur une page qui n'est que du texte, tu as mis une directive `client:*` de trop ou un import qui traîne une librairie lourde côté client. La commande `npx astro build --verbose` te liste ce qui est prérendu.

Failure mode classique : importer une librairie de date/markdown/icônes dans le frontmatter en croyant qu'elle est build-only, mais l'utiliser dans un `<script>` client ou la passer à un îlot — et hop, elle part dans le bundle. Une autre : oublier que `import.meta.env` dans Astro distingue `PUBLIC_*` (part au client) du reste (reste au build). Si une variable que tu croyais privée apparaît dans `dist/`, tu l'as mal préfixée. Et le piège de Vite : tout ce qui est sous `public/` est copié verbatim sans traitement — n'y mets pas de secrets et n'attends pas que Vite optimise ces assets.

EXERCICE

Crée un site Astro minimal avec deux pages : `/` (uniquement du HTML, zéro JS) et `/contador` avec un composant React `<Counter client:visible />`. Lance `npm run build`. Ouvre `dist/` et démontre avec DevTools Network que `/` télécharge 0 Ko de JS et que `/contador` ne s'hydrate que quand tu scrolles jusqu'au bouton. Documente la taille exacte des deux bundles.

LIVRABLE

Dossier `astro-islas/` avec le repo + un `MEDICIONES.md` qui liste : la taille de `dist/`, les Ko de JS par page (capture du Network) et une phrase expliquant pourquoi `/` n'envoie rien et `/contador` oui.

CLÉ ESSENTIELLE

La bonne question n'est jamais « quel framework j'utilise ? » mais « quel JS DOIT exister dans le navigateur ? ». Astro inverse le défaut du web : il part de zéro JS et t'oblige à justifier chaque octet d'interactivité par une directive explicite. Le jour où tu intègres que le frontmatter tourne au build et pas au runtime, tu arrêtes d'envoyer des déchets.

ERREURS À ÉVITER

  • ×Mettre `client:load` partout « au cas où » — tu hydrates le site entier et perds l'avantage d'Astro ; commence sans directive et n'ajoute `client:visible` que là où il y a une vraie interaction.
  • ×Croire que le JS du frontmatter (entre les `---`) tourne dans le navigateur ; il tourne au build, une seule fois, et sa sortie reste figée dans le HTML.
  • ×Mettre une variable secrète dans `import.meta.env.PUBLIC_*` — le préfixe PUBLIC l'expose dans `dist/` ; ce qui n'est pas public reste au build.
  • ×Confondre quand utiliser Astro vs Vite-SPA : contenu de lecture → Astro/SSG ; app à état dense et vivant comme Espejo → Vite+React.
  • ×Supposer que `public/` est optimisé : il est copié verbatim, sans hashing ni minification, ce n'est donc pas l'endroit pour les assets que tu veux faire traiter par Vite.
BD-02

Composants et design tokens

leçon

Construire un système visuel cohérent avec des design tokens (CSS custom properties) et des composants réutilisables, au lieu de CSS épars répété, de sorte qu'un changement de marque soit une ligne et non une chasse au trésor.

Le CSS épars ne passe pas à l'échelle : le jour où le client demande « le violet un peu plus foncé », tu finis par chercher `#7c3aed` dans 40 fichiers et tu en oublies trois. Les tokens transforment ce changement en une édition d'une variable. C'est la différence entre un produit maintenable et de la dette visuelle.

LA LEÇON

Un design token est une variable au nom sémantique, pas une valeur brute répétée. MOONKEY définit les siens dans `src/styles/global.css` : `--surface-base: #f8fafc`, `--text-primary: #0f172a`, plus la palette emerald/violet/amber. La règle d'or : les composants n'utilisent JAMAIS le hex directement, ils utilisent le token. Ainsi `--surface-base` apparaît une seule fois ; si la palette change, on change un seul endroit. L'anti-pattern, c'est d'écrire `color: #0f172a` dans chaque card — tu as couplé 30 fichiers à une valeur qui devrait avoir un nom.

Le cas le plus instructif du métier est dans Espejo, parce que là les tokens sont DYNAMIQUES par tenant. Regarde `src/brand/BrandProvider.tsx` : la fonction `applyTheme(brand)` fait `root.style.setProperty('--brand', brand.accent)` et dérive `--brand-soft`, `--brand-line`, `--brand-glow` avec une fonction `withAlpha(hex, a)` qui convertit le hex de marque en rgba avec transparence. Toute l'UI publique d'Espejo peint avec `var(--brand)`. Résultat : le même code sert Luna Rosa (rose `#e58fb0`) et n'importe quel influenceur, en changeant UN champ `accent`. C'est un design system qui est aussi le moteur du white-label.

Composant vs classe éparse : un composant encapsule structure + tokens + comportement sous un nom. MOONKEY a `PromptBlock`, `AnimalIcon`, `MoonkeyLogo`, `FlowDiagram` dans `src/components/`. Le signal que tu as besoin d'un composant : tu copies le même bloc de markup+classes une troisième fois. Jusqu'à deux fois, duplique ; à la troisième, extrais. N'abstrais pas avant — l'abstraction prématurée d'un composant avec 8 props « au cas où » est pire que la duplication honnête.

Avec Tailwind il y a un piège spécifique que MOONKEY documente explicitement : n'interpole JAMAIS de classes comme `bg-${theme}`. Tailwind fait du tree-shaking en scannant le code à la recherche de strings de classe COMPLETS au build ; une classe construite au runtime n'existe pas dans le CSS généré et se rend sans style. C'est pourquoi `galaxy.ts` a un objet `THEME` qui mappe chaque constellation à ses classes littérales (`violet`, `amber`, `emerald`…) déjà écrites. La leçon : les classes dynamiques se résolvent avec une MAP de strings littéraux, pas avec de l'interpolation.

La typographie comme token de système : MOONKEY fixe `font-display` = Space Grotesk pour h1–h3, Inter pour le body, JetBrains Mono pour les labels (`.label` = mono uppercase tracking-widest emerald-600). Ce n'est pas de la décoration éparse : c'est une décision codée une fois dont toute l'app hérite. Si chaque heading choisissait sa propre police, tu n'aurais pas une marque, tu aurais du bruit. Le système vit dans la couche de tokens/utilitaires, pas dans chaque `<h1>`.

Comment vérifier la cohérence : fais un grep des valeurs brutes qui ne DEVRAIENT PAS exister hors du fichier de tokens. `grep -rn '#[0-9a-fA-F]\{6\}' src/components/` dans MOONKEY devrait ne renvoyer presque rien — si un hex apparaît dans un composant, c'est un token qui a fui et qu'il faut remplacer par `var(--token)`. Ce grep est ton linter de cohérence visuelle fait main, et tu devrais le lancer avant chaque merge.

EXERCICE

Prends une landing avec 3 sections qui utilise des hex répétés. Extrais TOUTES les couleurs et radius vers des tokens dans un `:root` (`--surface`, `--text`, `--accent`, `--radius`). Puis réplique le pattern d'Espejo : ajoute une fonction `applyTheme(accent)` qui change `--accent` au runtime et démontre qu'avec UN appel toute la page change de marque. Vérifie avec `grep` qu'aucun hex ne survit hors du bloc de tokens.

LIVRABLE

Dossier `tokens-tenant/` avec `styles/tokens.css` (toutes les variables), les composants utilisant `var(--token)`, et un bouton qui appelle `applyTheme()` avec 3 couleurs différentes. Plus un log de `grep` prouvant 0 hex épars dans les composants.

CLÉ ESSENTIELLE

Un design system n'est pas une librairie de jolis composants : c'est l'indirection qui sépare « quelle valeur » de « où on l'utilise ». Le test définitif est celui d'Espejo — si tu peux changer la marque entière d'un produit white-label avec un seul `setProperty`, tu as un système ; si tu dois toucher plusieurs fichiers, tu as du CSS déguisé.

ERREURS À ÉVITER

  • ×Interpoler des classes Tailwind (`bg-${theme}`) — le JIT ne les voit pas au build et elles se rendent sans style ; utilise une map de strings littéraux comme le `THEME` de galaxy.ts.
  • ×Écrire des hex bruts dans les composants au lieu de `var(--token)` — tu couples N fichiers à une valeur qui devrait avoir un seul point de changement.
  • ×Abstraire un composant avec 8 props « au cas où » avant d'avoir 3 usages réels ; la duplication honnête vaut mieux que l'abstraction prématurée.
  • ×Laisser chaque heading choisir sa police au lieu de fixer la hiérarchie typographique une seule fois dans la couche de tokens.
  • ×Oublier de dériver les états de la couleur de marque (soft/line/glow) : un seul accent sans ses variantes en alpha produit des ombres et des bordures incohérentes — copie le pattern `withAlpha` d'Espejo.
BD-03

Multi-tenant & white-label

leçon

Concevoir un vrai produit multi-tenant white-label : une seule base de code qui sert de nombreux clients avec leur propre marque, leurs données et leur configuration, en utilisant le pattern de couture (seam) de Store qui isole l'UI du stockage.

Le white-label est le modèle économique qui multiplie un build par N clients sans rien réécrire. Mais il ne fonctionne que si l'isolement entre tenants est réel : une fuite de données d'un client vers un autre tue le produit. Le seam Store d'Espejo est l'architecture qui le rend possible et qui prépare en même temps la migration vers un backend.

LA LEÇON

Multi-tenant signifie : une instance du logiciel, de nombreux clients (tenants) qui la perçoivent comme la leur. Le white-label ajoute : chaque tenant la voit avec SA marque, sans aucune trace de la tienne. Espejo est le cas canonique — `src/brand/types.ts` le dit dans le commentaire : « chaque influenceur est un tenant avec sa marque ». Le tenant est identifié par un `slug` dans l'URL (`/r/<slug>`) et tout — nom, accent, glyph, voix, quelles lectures sont proposées, le CTA d'upsell — sort d'un objet `Brand`. Toute l'app publique se peint à partir de cet objet.

Le cœur architectural est le SEAM de données. Regarde `src/brand/store.ts` : il définit une `interface Store` avec des signatures async (`getBrand`, `listBrands`, `saveBrand`, `addSubscriber`, `listSubscribers`) et une implémentation `LocalStore` sur localStorage. Le commentaire le dit explicitement : « Aujourd'hui : localStorage. Demain : Supabase (mêmes signatures). L'UI ne touche jamais le stockage directement, seulement cette interface. » Cette interface est la couture : l'UI dépend de l'ABSTRACTION, pas de l'implémentation. Changer de backend = écrire une nouvelle classe qui implémente `Store`, sans toucher un seul composant.

Comment le tenant arrive jusqu'à l'UI : `BrandProvider.tsx` reçoit le `slug`, appelle `store.getBrand(slug)`, et s'il existe fait `applyTheme(brand)` et met le brand dans un Context React (`useBrand()`). S'il n'existe pas, il marque `notFound`. Pattern propre : un point d'entrée résout le tenant, applique son thème, et le fournit à tout l'arbre. N'importe quel composant fait `const brand = useBrand()` et peint le sien. Personne d'autre ne touche le storage ni ne décide du thème.

L'isolement entre tenants est la ligne rouge du white-label, et c'est ici qu'est le vrai risque : `listSubscribers(slug)` filtre par `brandSlug`. Dans localStorage c'est trivial mais FRAGILE — tout vit dans le même navigateur, il n'y a pas de vraie frontière de sécurité. Le moment critique est la migration vers Supabase : là, l'isolement DOIT passer à Postgres RLS (une ligne de subscriber n'est visible que pour son tenant), exactement la discipline que MOONKEY applique avec `is_admin()` et des policies self-or-admin. En multi-tenant réel, le filtre `WHERE slug = ...` côté client N'EST PAS de la sécurité ; la sécurité, c'est RLS dans la base.

Configuration par tenant sans branches de code : remarque qu'Espejo n'a pas de `if (tenant === 'lunarosa')` nulle part. La variation est de la DONNÉE (`Brand.readings`, `Brand.voice`, `Brand.offer`), pas de la logique conditionnelle. C'est la règle du white-label sain : les différences entre clients vivent dans la configuration (données), jamais dans des bifurcations de code. Dès que tu écris le premier `if (cliente X)`, tu as commencé à forker le produit et à perdre l'économie du modèle.

Il y a un garde-fou éthique spécifique dans Espejo qui n'est pas optionnel : la mémoire du projet précise qu'on NE construit PAS de siphon caché de leads ni de vente de données personnelles/biométriques — uniquement des données consenties de première partie et des agrégats anonymes. Dans un produit multi-tenant qui gère les données des utilisateurs finaux de tes clients, le modèle de consentement (`types.ts` a la version du texte de consentement accepté et `Brand.legal.responsible`) fait partie de l'architecture, pas d'un ajout juridique a posteriori. Le responsable du traitement est l'influenceur-tenant, et cela doit être modélisé dans les données.

EXERCICE

Construis un mini-produit white-label : une interface `Store` avec `getTenant(slug)`/`listItems(slug)`, un `LocalStore` sur localStorage avec 2 tenants seed (accent différent, nom différent). Un `TenantProvider` qui résout le slug de l'URL, applique le thème et le fournit par Context. Démontre qu'en changeant uniquement le slug dans l'URL, l'app entière change de marque, sans un seul `if` par client.

LIVRABLE

Dossier `white-label-seam/` avec `store.ts` (interface + LocalStore), `TenantProvider`, et deux routes `/t/<slug>` qui rendent deux marques différentes depuis le MÊME code. Un `README` qui indique où se trouve la couture et ce qu'il faudrait changer pour migrer vers un backend.

CLÉ ESSENTIELLE

Le seam Store d'Espejo est la pièce d'ingénierie la plus précieuse du produit : il sépare « ce que fait l'app » de « où vivent les données » derrière une interface de 5 signatures. Cette indirection est ce qui transforme une démo sur localStorage en un SaaS multi-tenant — et ce qui fait que le filtre par slug côté client ne pourra jamais être confondu avec de la vraie sécurité (celle-là vit dans RLS).

ERREURS À ÉVITER

  • ×Filtrer par tenant uniquement côté client (`WHERE slug=...` en JS) et croire que ça isole les données — en backend l'isolement DOIT être RLS dans Postgres, pas un filtre applicatif.
  • ×Mettre `if (cliente === 'X')` dans le code : dès que tu bifurques par client, tu as forké le produit ; la variation va dans les données/config, jamais dans la logique.
  • ×Coupler l'UI directement à localStorage/Supabase au lieu de l'interface `Store` — tu perds la couture et la migration devient une réécriture.
  • ×Oublier le modèle de consentement et le responsable du traitement par tenant : en multi-tenant avec des données d'utilisateurs finaux, c'est de l'architecture, pas une formalité (limite éthique d'Espejo : pas de siphon caché de données).
  • ×Ne pas définir le cas `tenant non trouvé` : sans un `notFound` explicite, un slug invalide casse l'app au lieu d'afficher un état propre.
BD-04

i18n : une galaxie en 6 langues

leçon

Architecturer l'internationalisation pour 6 langues sans dupliquer de pages, en comprenant les patterns de traduction, le fallback, et pourquoi les hrefs doivent être locale-aware.

L'i18n fait à la va-vite (un dossier de pages par langue) multiplie la maintenance par 6 et garantit que les traductions se désynchronisent. La bonne architecture a UN template et les langues sont des données, pas des copies. C'est la différence entre passer à 6 locales ou s'y noyer.

LA LEÇON

MOONKEY tourne avec 6 locales — `['es','en','th','fr','it','ch']` (config dans `src/i18n/config.ts`). ES vit à la racine ; le reste sous préfixe (`/en/`, `/th/`, `/fr/`, `/it/`, `/ch/`). La pièce qui évite de dupliquer les pages est l'i18n d'Astro avec `fallbackType: 'rewrite'` dans `astro.config.mjs` : pendant le build, Astro RÉÉCRIT chaque page avec le contexte de chaque locale, donc un unique `index.astro` génère 6 HTML traduits. Il n'y a pas de `pages/en/index.astro`, `pages/fr/index.astro`… il y en a UN. C'est la règle centrale : un template, N sorties.

Le fallback est ce qui rend le système robuste sans tout traduire d'un coup : `astro.config.mjs` déclare `fallback: { en:'es', th:'es', fr:'es', it:'es', ch:'es' }`, et la fonction `useTranslations` dans `src/i18n/utils.ts` implémente la même chaîne : `dict[key] ?? base[key] ?? key`. Autrement dit : s'il manque une clé en français, elle retombe sur l'espagnol ; si elle n'y est pas non plus, elle renvoie la clé elle-même (visible, facile à repérer). Ça te permet de LANCER avec des traductions partielles sans rien casser — le français affiche de l'espagnol là où tu n'as pas encore traduit, pas une page cassée.

MOONKEY utilise TROIS patterns d'i18n et CLAUDE.md prévient de l'incohérence, ce qui est une leçon de métier en soi : (1) dictionnaire `src/i18n/ui.ts` avec `t('key')` pour le chrome/nav court (~240 keys) ; (2) objets de contenu inline avec `cLocale = (es|th) ? locale : 'en'` qui collapse FR/IT/CH vers EN, pour le contenu lourd comme le curriculum ; (3) pages `src/pages/en/*.astro` qui surchargent SEULEMENT en anglais. Le pattern 3 est de la dette : il y a 5 pages racine codées en dur en espagnol, donc `/th/fr/it/ch` y affichent ES. Leçon : choisis UN pattern dominant (le dictionnaire avec fallback) et traite les autres comme de la dette à rembourser, pas comme une saine variété.

Le bug le plus coûteux de l'i18n avec préfixes est le href brut. Si tu écris `<a href='/cuenta'>` depuis une page sous `/fr/`, tu envoies l'utilisateur hors de sa langue. C'est pourquoi MOONKEY a `localizedPath(pathname, target)` dans `utils.ts` : il retire le préfixe de locale actuel, reconstruit la route et lui antépose le target (`/fr/cuenta`), en laissant ES à la racine. CLAUDE.md le marque comme obligatoire : « utilise `localizedPath('/ruta', locale)`, jamais `/ruta` brut ». Intègre ça : sur un site avec préfixes de langue, CHAQUE lien passe par la fonction de localisation, sans exception.

Détecter le locale actif a une subtilité que `utils.ts` résout : `resolveLocale(currentLocale, pathname)` préfère `Astro.currentLocale` à la détection par URL, parce que pendant un rewrite de fallback l'URL peut avoir perdu le préfixe et seul le contexte d'Astro connaît le vrai locale. Si tu détectes le locale uniquement par `pathname.split('/')`, tu te trompes précisément sur les pages qui utilisent le fallback. Règle : fais confiance au contexte du framework d'abord, à l'URL en secours.

Le sélecteur de langue a besoin de deux dictionnaires séparés : `LOCALE_LABELS` (ES, EN, TH…) pour le switcher compact et `LOCALE_NAMES` (Español, ไทย, Italiano…) pour le dropdown complet. Et attention aux cas rares : ici « ch » est Deutsch (CH), le suisse-allemand, pas le chinois — un commentaire dans `config.ts` le précise. Ce genre d'ambiguïté (un code qui ressemble à une autre langue) est exactement ce que tu documentes dans le code pour que personne ne traduise dans la mauvaise langue six mois plus tard.

EXERCICE

Monte un site Astro avec 3 locales (es à la racine, en/fr avec préfixe). Crée UN `index.astro` qui utilise `t('hero.title')` depuis un dictionnaire `ui.ts`. Implémente `useTranslations` avec le fallback `dict[key] ?? base[key] ?? key` et `localizedPath`. Laisse une clé SANS traduire en FR et démontre qu'elle retombe sur ES (sans casser). Mets un sélecteur de langue qui utilise `localizedPath` pour ne pas sortir l'utilisateur de son locale.

LIVRABLE

Dossier `i18n-3locales/` avec `i18n/config.ts`, `ui.ts`, `utils.ts` (useTranslations + localizedPath), un unique `index.astro` qui génère 3 HTML, et une capture montrant le FR retombant sur ES sur la clé non traduite.

CLÉ ESSENTIELLE

Un i18n bien fait transforme la langue en un paramètre de données sur UN template, pas en copies de pages. Le fallback en chaîne (`dict ?? base ?? key`) est ce qui te permet de lancer avec des traductions à moitié faites sans pages cassées — et `localizedPath` sur chaque href est la différence invisible entre un utilisateur qui reste dans sa langue et un qui retombe à la racine au premier clic.

ERREURS À ÉVITER

  • ×Dupliquer les pages par langue (`pages/fr/index.astro`, `pages/en/index.astro`…) au lieu d'un template avec rewrite — tu multiplies la maintenance par 6 et les traductions se désynchronisent.
  • ×Écrire des hrefs bruts (`/cuenta`) sous un préfixe de locale : tu sors l'utilisateur de sa langue ; tout lien passe par `localizedPath`.
  • ×Détecter le locale uniquement par l'URL : pendant un rewrite de fallback l'URL perd le préfixe ; préfère `Astro.currentLocale` (`resolveLocale`).
  • ×Mélanger trois patterns d'i18n sans un dominant — MOONKEY traîne déjà de la dette à cause de ça (5 pages codées en dur en ES qui s'affichent à th/fr/it/ch).
  • ×Supposer le sens d'un code de locale : ici « ch » est le suisse-allemand, pas le chinois ; documente les ambigus dans le config ou quelqu'un traduira mal.
BD-05

Déploiement : Cloudflare, domaines, env

leçon

Faire passer un site de localhost à une vraie URL sur Cloudflare Pages avec un domaine propre et des variables d'environnement gérées de façon sûre, en distinguant quel env part au client et lequel jamais.

Un projet qui n'est pas déployé n'existe pour personne. Et le deploy est l'endroit où le plus de secrets fuient : la frontière entre une variable publique et une privée est invisible jusqu'à ce que ta service_role key apparaisse dans le bundle de production. Savoir bien déployer, c'est la moitié du shipping.

LA LEÇON

MOONKEY se déploie sur Cloudflare Pages (`moonkeylab.pages.dev`) depuis le repo `garciafradepablo-pixel/xop`. Le flux de base : tu connectes le repo GitHub à Cloudflare Pages, tu définis le build command (`npm run build`) et l'output directory (`dist/` en Astro). Chaque push sur main déclenche un build en CI et publie. Tu ne montes pas de fichiers à la main : le deploy est une conséquence du git push. Ça te donne de la reproductibilité — ce qui est en production est exactement ce qui est dans le commit.

Pour une SSG pure comme MOONKEY, Cloudflare se contente de servir les HTML statiques du build ; il n'y a pas de runtime serveur. C'est clé pour le modèle de sécurité (tu le verras dans Ops) : comme il n'y a pas de handler côté serveur, le seul env qui compte en production est celui qui a été injecté AU BUILD. Si ton app a besoin de logique serveur (ce n'est pas le cas de MOONKEY ni de XNLAB de contenu), c'est là qu'entrent les Cloudflare Functions/Workers — mais ne les mets pas si tu n'en as pas besoin.

La frontière des secrets est LA leçon de ce module. En Astro/Vite, les variables `PUBLIC_*` (Astro) ou `VITE_*` (Vite pur comme Espejo) sont injectées dans le bundle du client — elles sont VISIBLES pour quiconque ouvre DevTools. Tout le reste reste au build. MOONKEY met la Supabase anon key en public exprès : CLAUDE.md dit « solo anon key » dans `src/lib/supabase.ts`, parce que l'anon key est CONÇUE pour être publique (son pouvoir est limité par RLS, pas par le secret). La règle absolue : la `service_role` key ne porte JAMAIS de préfixe public, ne va JAMAIS sur un site statique. Si tu la mets, elle est dans le bundle, et c'est game over.

Dans Cloudflare tu configures les env vars dans le dashboard (Settings → Environment variables), en séparant Production de Preview. Celles que ton build doit exposer au client portent le bon préfixe ; celles que seul le process de build utilise (tokens d'API tierces pour générer du contenu) ne le portent pas et ne finissent pas dans `dist/`. Vérification obligatoire post-deploy : télécharge ton JS de production et fais `grep -ri 'service_role\|secret\|sk_'` sur `dist/`. Si quelque chose apparaît, tu as une fuite à faire tourner immédiatement.

Domaine propre : dans Cloudflare Pages, Custom domains → tu ajoutes ton domaine, Cloudflare crée les enregistrements DNS (si le domaine est sur Cloudflare c'est automatique ; sinon tu ajoutes un CNAME vers le `*.pages.dev`). HTTPS est automatique. Le `.pages.dev` continue de fonctionner comme URL de secours. Pour les previews, chaque PR/branche génère sa propre URL éphémère — utilise-les pour relire les changements avant de merger sur main, sans toucher à la production.

Failure modes de deploy réels : (1) le build passe en local mais échoue en CI parce qu'une dépendance était dans `node_modules` local mais pas dans `package.json` — lance `npm ci` (pas `install`) au propre pour reproduire la CI. (2) Des variables qui existent en local (`.env`) mais que tu n'as pas configurées dans le dashboard → le build passe mais l'app échoue au runtime avec undefined. (3) Le warning cosmétique d'Astro sur `Astro.request.headers` dans les builds statiques avec i18n rewrite — `astro.config.mjs` le documente comme inoffensif, le build passe. Ne poursuis pas des warnings que la config a déjà signalés comme attendus.

EXERCICE

Déploie un site Astro sur Cloudflare Pages depuis un repo GitHub : configure le build command `npm run build`, l'output `dist/`, et une variable `PUBLIC_API_BASE` et une autre NON publique `BUILD_TOKEN`. Après le deploy, télécharge le JS de production et démontre avec `grep` que `PUBLIC_API_BASE` apparaît dans le bundle et que `BUILD_TOKEN` NON. Ajoute un domaine (ou sous-domaine) et vérifie le HTTPS.

LIVRABLE

Une URL publique vivante + un `DEPLOY.md` avec : la config de build, le tableau de quelle variable est publique et pourquoi, et le log de `grep` prouvant que le token de build n'a pas fui vers le client.

CLÉ ESSENTIELLE

Le deploy n'est pas « monter des fichiers », c'est figer un commit dans une URL avec une CI reproductible. Et la seule erreur irréparable est celle des secrets : comme un site statique n'a pas de serveur, tout ce dont il a besoin en production est injecté au build — c'est pourquoi l'anon key (limitée par RLS) peut être publique et la service_role jamais. Le `grep` sur `dist/` est ton dernier filet avant la fuite.

ERREURS À ÉVITER

  • ×Mettre une clé privée (service_role, secret d'API) avec un préfixe `PUBLIC_`/`VITE_` ou sur un site statique — elle reste dans le bundle visible ; seules les clés conçues pour être publiques (anon key limitée par RLS) vont au client.
  • ×Monter le build à la main au lieu de connecter le repo : tu perds la reproductibilité et tu ne sais pas quel commit est en production.
  • ×Configurer les env vars dans le `.env` local mais les oublier dans le dashboard de Cloudflare → le build passe et l'app explose au runtime.
  • ×Utiliser `npm install` pour reproduire la CI ; utilise `npm ci` au propre, qui respecte le lockfile et reproduit le vrai échec de dépendances.
  • ×Ne pas vérifier avec `grep` sur `dist/` après le deploy : la fuite d'un secret est silencieuse jusqu'à ce que quelqu'un la trouve.
BD-06

De localStorage au backend

leçon

Migrer la couche de données de localStorage vers un backend (Supabase) sans réécrire l'application, en tirant parti de la couture Store pour que le changement soit un swap d'implémentation, pas un refactor.

Toute app commence avec des données locales pour aller vite, mais localStorage ne partage pas entre appareils, ne persiste pas vraiment et n'a pas de vraie sécurité. Le saut vers un backend est inévitable — et si ton architecture ne l'a pas prévu, ce saut est une réécriture douloureuse. Avec la bonne couture, c'est une après-midi.

LA LEÇON

Reprends le seam de BD-03 : dans Espejo `src/brand/store.ts` définit `interface Store` avec des signatures async et `LocalStore` l'implémente sur localStorage. Le commentaire est la thèse entière du module : « Aujourd'hui : localStorage. Demain : Supabase (mêmes signatures). » Parce que l'interface est DÉJÀ async (`Promise<Brand | null>`), l'UI ne remarque pas la différence entre lire de la mémoire ou du réseau — le `await` est déjà là. Une API synchrone aurait rendu la migration impossible sans toucher chaque appel. Concevoir la couture async dès le premier jour est la décision qui paie ici.

Le swap concret : tu écris une classe `SupabaseStore implements Store`. `getBrand(slug)` passe de lire localStorage à `await supabase.from('brands').select().eq('slug', slug).single()`. `addSubscriber` passe de `write()` à `.insert()`. Les signatures sont IDENTIQUES. À la fin, une ligne change le monde : `export const store: Store = new SupabaseStore()` au lieu de `new LocalStore()`. Zéro composant touché. C'est ça que veut dire « changer la couche de données sans réécrire l'app » : le reste du code dépend de `Store`, pas de localStorage.

Mais migrer le stockage est la moitié facile. La moitié sérieuse est la SÉCURITÉ, et là le modèle entier change. Dans localStorage il n'y a pas de frontières : tout vit dans le navigateur de l'utilisateur. Dans Supabase, comme le client ne porte que l'anon key (MOONKEY : `src/lib/supabase.ts`, « solo anon key »), l'autorité N'EST PAS le client — c'est Postgres RLS. MOONKEY le formule sans ambiguïté : « autoridad = RLS, no el cliente ». Quand Espejo migrera les subscribers, le filtre `WHERE brandSlug = slug` qu'il fait aujourd'hui en JS DOIT devenir une policy RLS qui garantit qu'un tenant ne lit que SES subscribers. Le filtre client était de la commodité ; RLS est de la sécurité.

Pattern de référence de RLS dans MOONKEY : les tables `profiles/progress/feedback/proofs` ont un SELECT « self-or-admin » via `is_admin()` (fonction SECURITY DEFINER), vérifié par impersonation — un non-admin ne peut pas lire les lignes d'autrui. `proofs` INSERT exige une session et lie `user_id = auth.uid()` (policy `proofs_insert_self`). Les colonnes sensibles (`role`, `founder_badge`) sont immuables pour les non-admins via un trigger guard, donc personne ne s'auto-escalade. C'est le modèle mental pour toute migration : chaque opération (SELECT/INSERT/UPDATE) a besoin d'une policy qui tienne MÊME SI l'attaquant contrôle le client.

Hygiène de migration de données : il te faut un schéma (table `brands`, table `subscribers` avec `brand_slug`), appliqué comme MIGRATION versionnée (pas du SQL épars dans la console — MOONKEY : « changements de RLS/sécurité : migration versionnée + `get_advisors` après »). Puis un script à usage unique qui lit le localStorage existant et fait un `insert` massif vers le backend, idempotent (que tu puisses relancer sans dupliquer). Et une période de double écriture optionnelle si tu ne peux pas arrêter l'app : tu écris vers les deux jusqu'à confirmer la parité, puis tu coupes localStorage.

Attention à la frontière éthique qu'Espejo traîne : la mémoire du projet interdit le siphon caché de données personnelles/biométriques ; uniquement des données consenties de première partie et des agrégats anonymes. Migrer vers un backend AUGMENTE le pouvoir des données (maintenant elles persistent, se croisent, s'exportent), donc le modèle de consentement qui dans localStorage était presque théorique devient réel et auditable. La migration technique et la responsabilité sur les données montent ensemble — ne migre pas le stockage sans migrer aussi les garanties de confidentialité.

EXERCICE

Prends le `white-label-seam` de BD-03 (interface Store + LocalStore). Écris une `SupabaseStore implements Store` avec les MÊMES signatures contre une vraie table Supabase. Migre en changeant UNE ligne (`new LocalStore()` → `new SupabaseStore()`) sans toucher aux composants. Applique une policy RLS qui garantit que `listItems(slug)` ne renvoie que les lignes du bon tenant, et vérifie-le en impersonant un autre utilisateur.

LIVRABLE

Dossier `store-migration/` avec `LocalStore` et `SupabaseStore` côte à côte, le diff d'UNE ligne qui fait le swap, la migration SQL versionnée avec la policy RLS, et un log prouvant qu'un tenant NE PEUT PAS lire les données d'un autre (test d'impersonation).

CLÉ ESSENTIELLE

La couture Store transforme une migration de backend d'une réécriture en un swap d'une ligne — mais seulement si l'interface a été async dès le départ. Et la partie qui compte vraiment n'est pas de déplacer les octets : c'est qu'en passant à un client avec anon key, l'autorité se déplace de l'app vers Postgres RLS. Le filtre par slug que tu avais en JS n'était pas de la sécurité ; maintenant il doit l'être, dans la base.

ERREURS À ÉVITER

  • ×Concevoir l'interface Store synchrone et découvrir à la migration que chaque appel a besoin d'`await` — rends-la async (Promise) dès le premier jour même si localStorage n'en a pas besoin.
  • ×Croire que migrer le stockage est le travail : la partie sérieuse est de déplacer l'isolement du filtre JS client vers des policies RLS dans Postgres (« autoridad = RLS, no el cliente »).
  • ×Appliquer le schéma et les policies comme du SQL épars dans la console au lieu de migrations versionnées + `get_advisors` après.
  • ×Script de migration de données non idempotent : si tu ne peux pas le relancer sans dupliquer, une interruption te laisse des données corrompues.
  • ×Migrer vers un backend sans renforcer le consentement : persister et croiser des données personnelles élève le risque ; dans Espejo la limite est les données consenties de première partie, pas de siphon caché.

Constellation suivante

Signal

Quant & research