La galassia
ORBITA I · COSTELLAZIONE

Builders

Spedire prodotto

Trasformare un'idea in qualcosa che carica, ha un bell'aspetto e si deploya. Astro, componenti, multi-tenant, white-label e i18n. Il mestiere di Espejo e dei siti di XNLAB.

Alimenta EspejoXNLAB
6 moduli · 6 lezioni
MODULI DELLA COSTELLAZIONE Apri ogni modulo per la lezione completa
BD-01

Astro e Vite che caricano veloci

lezione

Costruire e capire un sito Astro che genera HTML statico per rotta e locale, e un progetto Vite SPA, sapendo esattamente quale JS arriva al browser e perché carica veloce.

Il 90% della lentezza del web moderno è JavaScript che non serviva inviare. Astro e Vite ti danno velocità di default, ma solo se capisci dove viene eseguita ogni cosa: build-time vs runtime. Confonderli è la causa profonda di siti statici che all'improvviso pesano 400KB di JS.

LA LEZIONE

Parti dalla distinzione che governa tutto: in Astro il codice di un componente .astro viene eseguito in BUILD (sulla tua macchina o in CI), non nel browser. Il risultato è HTML semplice. MOONKEY è esattamente questo: `astro.config.mjs` dichiara `i18n` con `fallbackType: 'rewrite'` e NON usa SSR. Quando lanci `npm run build`, Astro percorre `src/pages/`, esegue ogni .astro una volta per locale e scrive l'HTML in `dist/`. Non c'è server in produzione: Cloudflare Pages serve file. È questo che fa caricare `moonkeylab.pages.dev` in millisecondi.

Crea lo scheletro da zero per vederlo senza magia: `npm create astro@latest mi-sitio -- --template minimal --no-install`, poi `cd mi-sitio && npm install && npm run dev`. Apri `http://localhost:4321`. Modifica `src/pages/index.astro` e osserva come un `const hoy = new Date()` stampato con `{hoy}` si congela nel momento del build quando fai `npm run build`. Quello è l'aha-moment: il JS del frontmatter (tra i `---`) gira una sola volta, non a ogni visita.

Ora il controllo fine: le isole. Di default un componente di framework (React/Svelte) in Astro viene reso come HTML statico SENZA il suo JS. Solo se gli metti una direttiva `client:*` si idrata. `client:load` idrata al caricamento, `client:visible` quando entra nel viewport, `client:idle` quando il thread è libero. Regola del mestiere: parti SENZA direttiva. Aggiungi `client:visible` solo quando il componente HA BISOGNO di interattività reale. In MOONKEY l'`IntersectionObserver` del reveal vive in `Layout.astro` come uno `<script>` globale, non come un'isola per pagina — ed è la decisione giusta: uno script di 15 righe invece di idratare un intero framework.

Vite è il motore che sta sotto Astro ed è anche quello di Espejo (che è una SPA React pura: `vite.config.ts`, `@vitejs/plugin-react`). La differenza: Espejo SÌ invia React al browser perché ha bisogno di stato interattivo in tempo reale (letture della camera con MediaPipe, navigazione con react-router). Questa è la vera linea di confine del mestiere: il contenuto è prevalentemente da leggere (web di XNLAB, MOONKEY) o è un'app con stato denso (Espejo)? La prima chiede Astro/SSG; la seconda, Vite+SPA. Non mescolare religioni: non infilare un'intera SPA dentro Astro quando il 95% è contenuto.

Misura, non opinare. Lancia `npm run build` e guarda la dimensione di `dist/`. In MOONKEY un build di una pagina di contenuto dovrebbe avere ~0KB di JS di pagina (solo lo script di reveal condiviso). Apri DevTools → Network, ricarica con cache vuota e filtra per JS: se vedi un bundle di framework in una pagina che è solo testo, hai messo una direttiva `client:*` di troppo o un import che trascina una libreria pesante sul client. Il comando `npx astro build --verbose` ti elenca cosa viene prerenderizzato.

Failure mode classico: importare una libreria di date/markdown/icone nel frontmatter credendo che sia build-only, ma usarla dentro uno `<script>` client o passarla a un'isola — e di colpo finisce nel bundle. Un altro: dimenticare che `import.meta.env` in Astro distingue `PUBLIC_*` (va al client) dal resto (resta nel build). Se una variabile che credevi privata appare in `dist/`, l'hai prefissata male. E la trappola di Vite: qualunque cosa sotto `public/` viene copiata verbatim senza essere processata — non metterci segreti né aspettarti che Vite ottimizzi quegli asset.

ESERCIZIO

Crea un sito Astro minimo con due pagine: `/` (solo HTML, zero JS) e `/contador` con un componente React `<Counter client:visible />`. Lancia `npm run build`. Apri `dist/` e dimostra con DevTools Network che `/` scarica 0KB di JS e `/contador` si idrata solo quando fai scroll fino al pulsante. Documenta la dimensione esatta di entrambi i bundle.

CONSEGNA

Cartella `astro-islas/` con il repo + un `MEDICIONES.md` che elenchi: dimensione di `dist/`, KB di JS per pagina (screenshot di Network), e una frase che spieghi perché `/` non invia nulla e `/contador` sì.

INTUIZIONE CHIAVE

La domanda giusta non è mai 'quale framework uso?' ma 'quale JS DEVE esistere nel browser?'. Astro inverte il default del web: parte da zero JS e ti costringe a giustificare ogni byte di interattività con una direttiva esplicita. Il giorno in cui interiorizzi che il frontmatter gira in build e non in runtime, smetti di inviare spazzatura.

ERRORI DA EVITARE

  • ×Mettere `client:load` su tutto 'per sicurezza' — idrati l'intero sito e perdi il vantaggio di Astro; parti senza direttiva e aggiungi `client:visible` solo dove c'è interazione reale.
  • ×Credere che il JS del frontmatter (tra `---`) giri nel browser; gira in build, una sola volta, e il suo output resta congelato nell'HTML.
  • ×Mettere una variabile segreta in `import.meta.env.PUBLIC_*` — il prefisso PUBLIC la espone in `dist/`; ciò che non è pubblico resta nel build.
  • ×Confondere quando usare Astro vs Vite-SPA: contenuto da leggere → Astro/SSG; app con stato denso e vivo come Espejo → Vite+React.
  • ×Dare per scontato che `public/` venga ottimizzato: viene copiato verbatim, senza hashing né minificazione, quindi non è il posto per asset che vuoi far processare a Vite.
BD-02

Componenti e design token

lezione

Costruire un sistema visivo coerente con design token (CSS custom properties) e componenti riutilizzabili, invece di CSS sparso e ripetuto, in modo che un cambio di brand sia una riga e non una caccia.

Il CSS sparso non scala: il giorno in cui il cliente chiede 'il viola un po' più scuro' finisci a cercare `#7c3aed` in 40 file e te ne dimentichi tre. I token trasformano quel cambio in una modifica di una variabile. È la differenza tra un prodotto manutenibile e debito visivo.

LA LEZIONE

Un design token è una variabile con nome semantico, non un valore grezzo ripetuto. MOONKEY definisce i suoi in `src/styles/global.css`: `--surface-base: #f8fafc`, `--text-primary: #0f172a`, più la palette emerald/violet/amber. La regola d'oro: i componenti NON usano MAI l'hex diretto, usano il token. Così `--surface-base` appare una volta; se cambia la palette, cambi un solo posto. L'antipattern è scrivere `color: #0f172a` in ogni card — hai accoppiato 30 file a un valore che dovrebbe avere un nome.

Il caso più istruttivo del mestiere sta in Espejo, perché lì i token sono DINAMICI per tenant. Guarda `src/brand/BrandProvider.tsx`: la funzione `applyTheme(brand)` fa `root.style.setProperty('--brand', brand.accent)` e deriva `--brand-soft`, `--brand-line`, `--brand-glow` con una funzione `withAlpha(hex, a)` che converte l'hex del brand in rgba con trasparenza. Tutta la UI pubblica di Espejo dipinge con `var(--brand)`. Risultato: lo stesso codice serve Luna Rosa (rosa `#e58fb0`) e qualunque influencer, cambiando UN campo `accent`. Quello è un design system che è anche il motore del white-label.

Componente vs classe sparsa: un componente incapsula struttura + token + comportamento sotto un nome. MOONKEY ha `PromptBlock`, `AnimalIcon`, `MoonkeyLogo`, `FlowDiagram` in `src/components/`. Il segnale che ti serve un componente: copi lo stesso blocco di markup+classi una terza volta. Fino a due volte, duplica; alla terza, estrai. Non astrarre prima — l'astrazione prematura di un componente con 8 prop 'per sicurezza' è peggio della duplicazione onesta.

Con Tailwind c'è una trappola specifica che MOONKEY documenta esplicitamente: NON interpolare MAI classi come `bg-${theme}`. Tailwind fa tree-shaking scansionando il codice in cerca di stringhe di classe COMPLETE in build; una classe costruita in runtime non esiste nel CSS generato e viene resa senza stile. Per questo `galaxy.ts` ha un oggetto `THEME` che mappa ogni costellazione alle sue classi letterali (`violet`, `amber`, `emerald`…) già scritte. La lezione: le classi dinamiche si risolvono con una MAPPA di stringhe letterali, non con l'interpolazione.

Tipografia come token di sistema: MOONKEY fissa `font-display` = Space Grotesk per h1–h3, Inter per il body, JetBrains Mono per le label (`.label` = mono uppercase tracking-widest emerald-600). Non è decorazione sparsa: è una decisione codificata una volta che tutta l'app eredita. Se ogni heading scegliesse il proprio font, non avresti un brand, avresti rumore. Il sistema vive nel layer dei token/utility, non in ogni `<h1>`.

Come verificare la coerenza: fai un grep dei valori grezzi che NON dovrebbero esistere fuori dal file dei token. `grep -rn '#[0-9a-fA-F]\{6\}' src/components/` in MOONKEY dovrebbe restituire quasi nulla — se un hex appare in un componente, è un token sfuggito da sostituire con `var(--token)`. Quel grep è il tuo linter di coerenza visiva fatto a mano, e dovresti lanciarlo prima di ogni merge.

ESERCIZIO

Prendi una landing con 3 sezioni che usi hex ripetuti. Estrai TUTTI i colori e i raggi in token dentro un `:root` (`--surface`, `--text`, `--accent`, `--radius`). Poi replica il pattern di Espejo: aggiungi una funzione `applyTheme(accent)` che cambi `--accent` in runtime e dimostra che con UNA chiamata l'intera pagina cambia brand. Verifica con `grep` che nessun hex sopravviva fuori dal blocco dei token.

CONSEGNA

Cartella `tokens-tenant/` con `styles/tokens.css` (tutte le variabili), i componenti che usano `var(--token)`, e un pulsante che chiama `applyTheme()` con 3 colori diversi. Più un log di `grep` che provi 0 hex sparsi nei componenti.

INTUIZIONE CHIAVE

Un design system non è una libreria di componenti carini: è l'indirezione che separa 'quale valore' da 'dove viene usato'. Il test definitivo è quello di Espejo — se puoi cambiare l'intero brand di un prodotto white-label con un solo `setProperty`, hai un sistema; se devi toccare più file, hai CSS travestito.

ERRORI DA EVITARE

  • ×Interpolare classi di Tailwind (`bg-${theme}`) — il JIT non le vede in build e vengono rese senza stile; usa una mappa di stringhe letterali come il `THEME` di galaxy.ts.
  • ×Scrivere hex grezzi nei componenti invece di `var(--token)` — accoppi N file a un valore che dovrebbe avere un solo punto di cambio.
  • ×Astrarre un componente con 8 prop 'per sicurezza' prima di avere 3 usi reali; la duplicazione onesta è meglio dell'astrazione prematura.
  • ×Lasciare che ogni heading scelga il suo font invece di fissare la gerarchia tipografica una volta nel layer dei token.
  • ×Dimenticare di derivare gli stati del colore di brand (soft/line/glow): un solo accent senza le sue varianti con alpha produce ombre e bordi incoerenti — copia il pattern `withAlpha` di Espejo.
BD-03

Multi-tenant e white-label

lezione

Progettare un prodotto multi-tenant white-label reale: un'unica base di codice che serve molti clienti con il proprio brand, dati e configurazione, usando il pattern del seam di Store che isola la UI dallo storage.

Il white-label è il modello di business che moltiplica un build per N clienti senza riscrivere nulla. Ma funziona solo se l'isolamento tra tenant è reale: una fuga di dati da un cliente all'altro uccide il prodotto. Il seam Store di Espejo è l'architettura che lo rende possibile e allo stesso tempo prepara la migrazione al backend.

LA LEZIONE

Multi-tenant significa: un'unica istanza del software, molti clienti (tenant) che la percepiscono come propria. White-label aggiunge: ogni tenant la vede con il SUO brand, senza traccia del tuo. Espejo è il caso canonico — `src/brand/types.ts` lo dice nel commento: 'ogni influencer è un tenant con il proprio brand'. Il tenant è identificato da uno `slug` nell'URL (`/r/<slug>`) e tutto —nome, accent, glyph, voce, quali letture vengono offerte, la CTA di upsell— esce da un oggetto `Brand`. L'intera app pubblica viene dipinta a partire da quell'oggetto.

Il cuore architetturale è il SEAM dei dati. Guarda `src/brand/store.ts`: definisce un'`interface Store` con firme async (`getBrand`, `listBrands`, `saveBrand`, `addSubscriber`, `listSubscribers`) e un'implementazione `LocalStore` su localStorage. Il commento lo dice esplicitamente: 'Oggi: localStorage. Domani: Supabase (stesse firme). La UI non tocca mai lo storage direttamente, solo questa interfaccia.' Quell'interfaccia è il seam: la UI dipende dall'ASTRAZIONE, non dall'implementazione. Cambiare backend = scrivere una nuova classe che implementi `Store`, senza toccare un solo componente.

Come il tenant arriva alla UI: `BrandProvider.tsx` riceve lo `slug`, chiama `store.getBrand(slug)`, e se esiste fa `applyTheme(brand)` e mette il brand in un Context React (`useBrand()`). Se non esiste, segna `notFound`. Pattern pulito: un punto di ingresso risolve il tenant, applica il suo tema e lo fornisce a tutto l'albero. Qualunque componente fa `const brand = useBrand()` e dipinge il suo. Nessun altro tocca lo storage né decide il tema.

L'isolamento tra tenant è la linea rossa del white-label, e qui sta il rischio reale: `listSubscribers(slug)` filtra per `brandSlug`. In localStorage è banale ma FRAGILE — tutto vive nello stesso browser, non c'è una vera frontiera di sicurezza. Il momento critico è la migrazione a Supabase: lì l'isolamento DEVE spostarsi su Postgres RLS (una riga di subscriber è visibile solo per il suo tenant), esattamente la disciplina che MOONKEY applica con `is_admin()` e policy self-or-admin. In un multi-tenant reale, il filtro `WHERE slug = ...` sul client NON è sicurezza; la sicurezza è RLS nel database.

Configurazione per tenant senza rami di codice: nota che Espejo non ha `if (tenant === 'lunarosa')` da nessuna parte. La variazione è DATI (`Brand.readings`, `Brand.voice`, `Brand.offer`), non logica condizionale. Questa è la regola del white-label sano: le differenze tra clienti vivono nella configurazione (dati), mai in biforcazioni di codice. Nel momento in cui scrivi il primo `if (cliente X)` hai iniziato a forkare il prodotto e a perdere l'economia del modello.

C'è un guardrail etico specifico in Espejo che non è opzionale: la memoria del progetto segna che NON si costruisce un sifone occulto di lead né vendita di dati personali/biometrici — solo dati consentiti di prima parte e aggregati anonimi. In un prodotto multi-tenant che gestisce dati degli utenti finali dei tuoi clienti, il modello di consenso (`types.ts` ha la versione del testo di consenso accettato e `Brand.legal.responsible`) è parte dell'architettura, non un'aggiunta legale posteriore. Il responsabile del trattamento è l'influencer-tenant, e questo deve essere modellato nei dati.

ESERCIZIO

Costruisci un mini prodotto white-label: un'interfaccia `Store` con `getTenant(slug)`/`listItems(slug)`, una `LocalStore` su localStorage con 2 tenant seed (accent diverso, nome diverso). Un `TenantProvider` che risolva lo slug dell'URL, applichi il tema e lo fornisca via Context. Dimostra che cambiando solo lo slug nell'URL l'intera app cambia brand, senza un solo `if` per cliente.

CONSEGNA

Cartella `white-label-seam/` con `store.ts` (interface + LocalStore), `TenantProvider`, e due rotte `/t/<slug>` che renderizzano due brand diversi dallo STESSO codice. Un `README` che indichi dove sta il seam e cosa si cambierebbe per migrare al backend.

INTUIZIONE CHIAVE

Il seam Store di Espejo è il pezzo di ingegneria più prezioso del prodotto: separa 'cosa fa l'app' da 'dove vivono i dati' dietro un'interfaccia di 5 firme. Quell'indirezione è ciò che trasforma una demo in localStorage in un SaaS multi-tenant — e ciò che fa sì che il filtro per slug del client non possa mai essere confuso con sicurezza reale (quella vive in RLS).

ERRORI DA EVITARE

  • ×Filtrare per tenant solo sul client (`WHERE slug=...` in JS) e credere che questo isoli i dati — sul backend l'isolamento DEVE essere RLS in Postgres, non un filtro applicativo.
  • ×Mettere `if (cliente === 'X')` nel codice: nel momento in cui biforchi per cliente hai forkato il prodotto; la variazione va nei dati/config, mai nella logica.
  • ×Accoppiare la UI direttamente a localStorage/Supabase invece che all'interfaccia `Store` — perdi il seam e la migrazione diventa una riscrittura.
  • ×Dimenticare il modello di consenso e il responsabile del trattamento per tenant: in multi-tenant con dati di utenti finali, è architettura, non burocrazia (limite etico di Espejo: niente sifone occulto di dati).
  • ×Non definire il caso `tenant non trovato`: senza un `notFound` esplicito, uno slug non valido rompe l'app invece di mostrare uno stato pulito.
BD-04

i18n: una galassia in 6 lingue

lezione

Architettare l'internazionalizzazione per 6 lingue senza duplicare pagine, capendo i pattern di traduzione, il fallback, e perché gli href devono essere locale-aware.

L'i18n fatto a forza bruta (una cartella di pagine per lingua) moltiplica la manutenzione per 6 e garantisce che le traduzioni si desincronizzino. L'architettura corretta ha UN template e le lingue sono dati, non copie. È la differenza tra scalare a 6 locale o annegarci.

LA LEZIONE

MOONKEY gira con 6 locale — `['es','en','th','fr','it','ch']` (config in `src/i18n/config.ts`). ES vive nella radice; il resto sotto prefisso (`/en/`, `/th/`, `/fr/`, `/it/`, `/ch/`). Il pezzo che evita di duplicare pagine è Astro i18n con `fallbackType: 'rewrite'` in `astro.config.mjs`: durante il build, Astro RISCRIVE ogni pagina con il contesto di ogni locale, così un unico `index.astro` genera 6 HTML tradotti. Non c'è `pages/en/index.astro`, `pages/fr/index.astro`… ce n'è UNO. Questa è la regola centrale: un template, N output.

Il fallback è ciò che rende il sistema robusto senza tradurre tutto in un colpo: `astro.config.mjs` dichiara `fallback: { en:'es', th:'es', fr:'es', it:'es', ch:'es' }`, e la funzione `useTranslations` in `src/i18n/utils.ts` implementa la stessa catena: `dict[key] ?? base[key] ?? key`. Cioè: se manca una chiave in francese, cade allo spagnolo; se non c'è neanche quella, restituisce la chiave stessa (visibile, facile da scovare). Questo ti permette di LANCIARE con traduzioni parziali senza rompere nulla — il francese mostra spagnolo dove non hai ancora tradotto, non una pagina rotta.

MOONKEY usa TRE pattern di i18n e CLAUDE.md avvisa dell'incoerenza, il che è una lezione di mestiere di per sé: (1) dizionario `src/i18n/ui.ts` con `t('key')` per chrome/nav corti (~240 chiavi); (2) oggetti di contenuto inline con `cLocale = (es|th) ? locale : 'en'` che collassa FR/IT/CH a EN, per contenuto pesante come il curriculum; (3) pagine `src/pages/en/*.astro` che fanno override SOLO in inglese. Il pattern 3 è debito: ci sono 5 pagine radice hardcoded in spagnolo, quindi `/th/fr/it/ch` mostrano ES su di esse. Lezione: scegli UN pattern dominante (il dizionario con fallback) e tratta gli altri come debito da saldare, non come varietà sana.

Il bug più costoso dell'i18n con prefissi è l'href grezzo. Se scrivi `<a href='/cuenta'>` da una pagina sotto `/fr/`, mandi l'utente fuori dalla sua lingua. Per questo MOONKEY ha `localizedPath(pathname, target)` in `utils.ts`: rimuove il prefisso del locale attuale, ricostruisce la rotta e antepone il target (`/fr/cuenta`), lasciando ES nella radice. CLAUDE.md lo marca come obbligatorio: 'usa `localizedPath('/ruta', locale)`, mai `/ruta` grezzo'. Interiorizza questo: in un sito con prefissi di lingua, OGNI link passa per la funzione di localizzazione, senza eccezioni.

Rilevare il locale attivo ha una sottigliezza che `utils.ts` risolve: `resolveLocale(currentLocale, pathname)` preferisce `Astro.currentLocale` al rilevamento da URL, perché durante un rewrite di fallback l'URL può aver perso il prefisso e solo il contesto di Astro conosce il locale reale. Se rilevi il locale solo da `pathname.split('/')` sbagli proprio nelle pagine che usano il fallback. Regola: fidati prima del contesto del framework, dell'URL come ripiego.

Il selettore di lingua ha bisogno di due dizionari separati: `LOCALE_LABELS` (ES, EN, TH…) per lo switcher compatto e `LOCALE_NAMES` (Español, ไทย, Italiano…) per il dropdown completo. E attenzione ai casi strani: 'ch' qui è Deutsch (CH), svizzero-tedesco, non cinese — un commento in `config.ts` lo chiarisce. Quel tipo di ambiguità (un codice che sembra un'altra lingua) è esattamente ciò che documenti nel codice perché nessuno traduca nella lingua sbagliata sei mesi dopo.

ESERCIZIO

Monta un sito Astro con 3 locale (es radice, en/fr con prefisso). Crea UN `index.astro` che usi `t('hero.title')` da un dizionario `ui.ts`. Implementa `useTranslations` con fallback `dict[key] ?? base[key] ?? key` e `localizedPath`. Lascia una chiave NON tradotta in FR e dimostra che cade a ES (non si rompe). Metti un selettore di lingua che usi `localizedPath` per non far uscire l'utente dal suo locale.

CONSEGNA

Cartella `i18n-3locales/` con `i18n/config.ts`, `ui.ts`, `utils.ts` (useTranslations + localizedPath), un `index.astro` unico che genera 3 HTML, e uno screenshot che mostri FR cadere a ES sulla chiave non tradotta.

INTUIZIONE CHIAVE

L'i18n fatto bene trasforma la lingua in un parametro di dati su UN template, non in copie di pagine. Il fallback a catena (`dict ?? base ?? key`) è ciò che ti permette di lanciare con traduzioni a metà senza pagine rotte — e `localizedPath` su ogni href è la differenza invisibile tra un utente che resta nella sua lingua e uno che casca alla radice al primo clic.

ERRORI DA EVITARE

  • ×Duplicare pagine per lingua (`pages/fr/index.astro`, `pages/en/index.astro`…) invece di un template con rewrite — moltiplichi la manutenzione per 6 e le traduzioni si desincronizzano.
  • ×Scrivere href grezzi (`/cuenta`) sotto un prefisso di locale: fai uscire l'utente dalla sua lingua; ogni link passa per `localizedPath`.
  • ×Rilevare il locale solo dall'URL: durante un rewrite di fallback l'URL perde il prefisso; preferisci `Astro.currentLocale` (`resolveLocale`).
  • ×Mescolare tre pattern di i18n senza uno dominante — MOONKEY si trascina già debito per questo (5 pagine hardcoded in ES che vengono mostrate a th/fr/it/ch).
  • ×Dare per scontato il significato di un codice di locale: 'ch' qui è svizzero-tedesco, non cinese; documenta gli ambigui nella config o qualcuno tradurrà nella lingua sbagliata.
BD-05

Deploy: Cloudflare, domini, env

lezione

Portare un sito da localhost a un URL reale su Cloudflare Pages con dominio proprio e variabili d'ambiente gestite in modo sicuro, distinguendo quale env va al client e quale mai.

Un progetto che non è deployato non esiste per nessuno. E il deploy è dove si fanno trapelare più segreti: la linea tra una variabile pubblica e una privata è invisibile finché la tua service_role key non appare nel bundle di produzione. Saper deployare bene è metà dello shippare.

LA LEZIONE

MOONKEY viene deployato su Cloudflare Pages (`moonkeylab.pages.dev`) dal repo `garciafradepablo-pixel/xop`. Il flusso base: colleghi il repo GitHub a Cloudflare Pages, definisci il build command (`npm run build`) e l'output directory (`dist/` in Astro). Ogni push su main scatena un build in CI e pubblica. Non carichi file a mano: il deploy è una conseguenza di git push. Questo ti dà riproducibilità — ciò che è in produzione è esattamente ciò che è nel commit.

Per una SSG pura come MOONKEY, Cloudflare serve solo gli HTML statici del build; non c'è un runtime di server. Questo è chiave per il modello di sicurezza (lo vedrai in Ops): siccome non c'è un handler server-side, l'unico env che conta in produzione è quello iniettato IN BUILD. Se la tua app ha bisogno di logica server (non è il caso di MOONKEY né di XNLAB di contenuto), lì entrano in gioco Cloudflare Functions/Workers — ma non metterle se non ti servono.

La frontiera dei segreti è LA lezione di questo modulo. In Astro/Vite, le variabili `PUBLIC_*` (Astro) o `VITE_*` (Vite puro come Espejo) vengono iniettate nel bundle del client — sono VISIBILI per chiunque apra DevTools. Tutto il resto resta nel build. MOONKEY mette la Supabase anon key come pubblica di proposito: CLAUDE.md dice 'solo anon key' in `src/lib/supabase.ts`, perché l'anon key è PROGETTATA per essere pubblica (il suo potere è limitato da RLS, non dal segreto). La regola assoluta: la `service_role` key NON porta MAI il prefisso pubblico, NON va MAI su un sito statico. Se la metti, è nel bundle, ed è game over.

In Cloudflare configuri le env vars nel dashboard (Settings → Environment variables), separando Production da Preview. Quelle che il tuo build deve esporre al client portano il prefisso corretto; quelle che usa solo il processo di build (token di API di terze parti per generare contenuto) non lo portano e non finiscono in `dist/`. Verifica obbligatoria post-deploy: scarica il tuo JS di produzione e fai `grep -ri 'service_role\|secret\|sk_'` su `dist/`. Se qualcosa appare, hai una fuga da ruotare immediatamente.

Dominio proprio: in Cloudflare Pages, Custom domains → aggiungi il tuo dominio, Cloudflare crea i record DNS (se il dominio è su Cloudflare è automatico; se no, aggiungi un CNAME al `*.pages.dev`). HTTPS è automatico. Il `.pages.dev` continua a funzionare come URL di riserva. Per le preview, ogni PR/branch genera il proprio URL effimero — usali per rivedere i cambiamenti prima di mergere su main, senza toccare la produzione.

Failure mode di deploy reali: (1) il build passa in locale ma fallisce in CI perché una dipendenza era in `node_modules` locale ma non in `package.json` — lancia `npm ci` (non `install`) da pulito per riprodurre CI. (2) Variabili che esistono in locale (`.env`) ma non hai configurato nel dashboard → il build passa ma l'app fallisce in runtime con undefined. (3) Il warning cosmetico di Astro su `Astro.request.headers` in build statici con i18n rewrite — `astro.config.mjs` lo documenta come innocuo, il build passa. Non inseguire warning che la config ha già marcato come attesi.

ESERCIZIO

Deploya un sito Astro su Cloudflare Pages da un repo GitHub: configura il build command `npm run build`, output `dist/`, e una variabile `PUBLIC_API_BASE` e un'altra NON pubblica `BUILD_TOKEN`. Dopo il deploy, scarica il JS di produzione e dimostra con `grep` che `PUBLIC_API_BASE` appare nel bundle e `BUILD_TOKEN` NO. Aggiungi un dominio (o sottodominio) e verifica HTTPS.

CONSEGNA

Un URL pubblico vivo + un `DEPLOY.md` con: il build config, la tabella di quale variabile è pubblica e perché, e il log di `grep` che provi che il token di build non è trapelato al client.

INTUIZIONE CHIAVE

Il deploy non è 'caricare file', è congelare un commit in un URL con CI riproducibile. E l'unico errore irreparabile è quello dei segreti: siccome un sito statico non ha server, tutto ciò che gli serve in produzione viene iniettato in build — per questo l'anon key (limitata da RLS) può essere pubblica e la service_role mai. Il `grep` su `dist/` è la tua ultima rete prima di far trapelare.

ERRORI DA EVITARE

  • ×Mettere una chiave privata (service_role, secret di API) con prefisso `PUBLIC_`/`VITE_` o su un sito statico — resta nel bundle visibile; solo le chiavi progettate per essere pubbliche (anon key limitata da RLS) vanno al client.
  • ×Caricare il build a mano invece di collegare il repo: perdi la riproducibilità e non sai quale commit è in produzione.
  • ×Configurare le env vars in `.env` locale ma dimenticarle nel dashboard di Cloudflare → il build passa e l'app esplode in runtime.
  • ×Usare `npm install` per riprodurre CI; usa `npm ci` da pulito, che rispetta il lockfile e riproduce il vero fallimento di dipendenze.
  • ×Non verificare con `grep` su `dist/` dopo il deploy: la fuga di un segreto è silenziosa finché qualcuno non la trova.
BD-06

Da localStorage al backend

lezione

Migrare il layer di dati da localStorage a un backend (Supabase) senza riscrivere l'applicazione, sfruttando il seam Store perché il cambio sia uno swap di implementazione, non un refactor.

Ogni app inizia con dati locali per andare veloce, ma localStorage non condivide tra dispositivi, non persiste davvero e non ha sicurezza reale. Il salto al backend è inevitabile — e se la tua architettura non l'ha previsto, quel salto è una riscrittura dolorosa. Con il seam corretto, è un pomeriggio.

LA LEZIONE

Recupera il seam di BD-03: in Espejo `src/brand/store.ts` definisce `interface Store` con firme async e `LocalStore` la implementa su localStorage. Il commento è l'intera tesi del modulo: 'Oggi: localStorage. Domani: Supabase (stesse firme).' Siccome l'interfaccia è GIÀ async (`Promise<Brand | null>`), la UI non nota la differenza tra leggere dalla memoria o dalla rete — l'`await` è già lì. Un'API sincrona avrebbe reso la migrazione impossibile senza toccare ogni chiamata. Progettare il seam async dal giorno uno è la decisione che ripaga qui.

Lo swap concreto: scrivi una classe `SupabaseStore implements Store`. `getBrand(slug)` passa dal leggere localStorage a `await supabase.from('brands').select().eq('slug', slug).single()`. `addSubscriber` passa da `write()` a `.insert()`. Le firme sono IDENTICHE. Alla fine, una riga cambia il mondo: `export const store: Store = new SupabaseStore()` invece di `new LocalStore()`. Zero componenti toccati. È questo che significa 'cambiare il layer di dati senza riscrivere l'app': il resto del codice dipende da `Store`, non da localStorage.

Ma migrare lo storage è la metà facile. La metà seria è la SICUREZZA, e qui cambia l'intero modello. In localStorage non ci sono frontiere: tutto vive nel browser dell'utente. In Supabase, siccome il client porta solo l'anon key (MOONKEY: `src/lib/supabase.ts`, 'solo anon key'), l'autorità NON è il client — è Postgres RLS. MOONKEY lo formula senza ambiguità: 'autorità = RLS, non il client'. Quando Espejo migrerà i subscriber, il filtro `WHERE brandSlug = slug` che oggi fa in JS DEVE diventare una policy RLS che garantisca che un tenant legga solo i SUOI subscriber. Il filtro del client era convenienza; RLS è sicurezza.

Pattern di riferimento di RLS in MOONKEY: le tabelle `profiles/progress/feedback/proofs` hanno SELECT 'self-or-admin' via `is_admin()` (funzione SECURITY DEFINER), verificato per impersonazione — un non-admin non può leggere righe altrui. `proofs` INSERT richiede una sessione e lega `user_id = auth.uid()` (policy `proofs_insert_self`). Colonne sensibili (`role`, `founder_badge`) sono immutabili per i non-admin via trigger guard, così nessuno si auto-scala. Quella è la matrice mentale per qualunque migrazione: ogni operazione (SELECT/INSERT/UPDATE) ha bisogno di una policy che valga ANCHE SE l'attaccante controlla il client.

Igiene della migrazione dei dati: ti serve uno schema (tabella `brands`, tabella `subscribers` con `brand_slug`), applicato come MIGRAZIONE versionata (non SQL sparso nella console — MOONKEY: 'cambi di RLS/sicurezza: migrazione versionata + `get_advisors` dopo'). Poi uno script una-tantum che legga il localStorage esistente e faccia un `insert` massivo al backend, idempotente (che tu possa rilanciare senza duplicare). E un periodo di doppia scrittura opzionale se non puoi fermare l'app: scrivi su entrambi finché non confermi la parità, poi stacchi localStorage.

Attenzione alla frontiera etica che si trascina Espejo: la memoria del progetto proibisce il sifone occulto di dati personali/biometrici; solo dati consentiti di prima parte e aggregati anonimi. Migrare al backend AUMENTA il potere dei dati (ora persistono, si incrociano, si esportano), quindi il modello di consenso che in localStorage era quasi teorico diventa reale e verificabile. La migrazione tecnica e la responsabilità sui dati salgono insieme — non migrare lo storage senza migrare anche le garanzie di privacy.

ESERCIZIO

Prendi il `white-label-seam` di BD-03 (interface Store + LocalStore). Scrivi una `SupabaseStore implements Store` con le STESSE firme contro una tabella reale di Supabase. Migra cambiando UNA riga (`new LocalStore()` → `new SupabaseStore()`) senza toccare i componenti. Applica una policy RLS che garantisca che `listItems(slug)` restituisca solo righe del tenant corretto, e verificalo impersonando un altro utente.

CONSEGNA

Cartella `store-migration/` con `LocalStore` e `SupabaseStore` fianco a fianco, il diff di UNA riga che fa lo swap, la migrazione SQL versionata con la policy RLS, e un log che provi che un tenant NON può leggere i dati di un altro (test di impersonazione).

INTUIZIONE CHIAVE

Il seam Store trasforma una migrazione di backend da riscrittura a swap di una riga — ma solo se l'interfaccia è stata async fin dall'inizio. E la parte che conta davvero non è spostare i byte: è che passando a un client con anon key, l'autorità si sposta dall'app a Postgres RLS. Il filtro per slug che avevi in JS non era sicurezza; ora deve esserlo, nel database.

ERRORI DA EVITARE

  • ×Progettare l'interfaccia Store sincrona e scoprire in fase di migrazione che ogni chiamata ha bisogno di `await` — rendila async (Promise) dal giorno uno anche se localStorage non lo richiede.
  • ×Credere che migrare lo storage sia il lavoro: la parte seria è spostare l'isolamento dal filtro JS del client a policy RLS in Postgres ('autorità = RLS, non il client').
  • ×Applicare lo schema e le policy come SQL sparso nella console invece di migrazioni versionate + `get_advisors` dopo.
  • ×Script di migrazione dei dati non idempotente: se non puoi rilanciarlo senza duplicare, un'interruzione ti lascia dati corrotti.
  • ×Migrare al backend senza rafforzare il consenso: persistere e incrociare dati personali eleva il rischio; in Espejo il limite è dati consentiti di prima parte, niente sifone occulto.

Costellazione successiva

Signal

Quant e research