Ops & Security
La disciplina
La differenza tra un progetto e un incidente. Segreti, threat model, audit avversariale e isolamento tra progetti. La disciplina che protegge tutta la galassia.
OS-01 Segreti: pubblico vs privato
lezione Decidere, per qualsiasi chiave o token dei tuoi progetti, se può vivere nel client o non deve mai uscire dal server, e costruire l'albero decisionale che ti impedisce di sbagliare di nuovo.
Segreti: pubblico vs privato
lezioneDecidere, per qualsiasi chiave o token dei tuoi progetti, se può vivere nel client o non deve mai uscire dal server, e costruire l'albero decisionale che ti impedisce di sbagliare di nuovo.
Una sola service_role key finita in un bundle di Astro è accesso totale al tuo database scavalcando RLS. Non è un bug che si patcha: è una breccia che obbliga a ruotare le credenziali, verificare gli accessi e, se ci sono dati di utenti, notificare. Il costo di sbagliare qui è il più alto di tutta la galassia.
LA LEZIONE
Parti dalla regola mentale che non sbaglia mai: tutto ciò che viene compilato in un sito statico è pubblico. MOONKEY LAB è Astro 4 in modalità SSG — il build genera HTML+JS piatto servito da Cloudflare Pages. Non c'è un server che custodisca segreti a runtime. Qualsiasi stringa che importi in un componente .astro e che arriva al client finisce, letteralmente, in un file che chiunque scarica con `view-source` o aprendo DevTools → Sources. Per questo `src/lib/supabase.ts` contiene SOLO la anon key. La anon key è PROGETTATA per essere pubblica: è un identificatore di progetto + un token con permessi di ruolo `anon`, la cui autorità reale la limita Postgres RLS. Non è un segreto; è un indirizzo con un portiere.
Ora il contrasto che devi interiorizzare. Ci sono due chiavi Supabase con lo stesso aspetto (un JWT lungo) ma universi opposti di potere. La `anon` key opera sotto il ruolo `anon`/`authenticated` ed è SOTTOPOSTA a RLS: vede solo ciò che le policy permettono. La `service_role` key opera con `BYPASSRLS`: ignora tutte le policy, legge e scrive qualsiasi riga di qualsiasi tabella — incluse quelle di XHUB IRON (`iron_*`, `world_*`) che condividono la stessa istanza `wuchsslgbqlhyxljsmxi`. Se la `service_role` compare nel client, un attaccante non compromette solo MOONKEY: pivota verso tutti i progetti che vivono in quel DB. Regola dura: la `service_role` NON entra MAI in un repo di frontend, né in un `.astro`, né in un `import.meta.env.PUBLIC_*`. Il suo posto è un backend con sessione (una Edge Function, un processo server) o il tuo portachiavi locale — mai il bundle.
Il vettore di fuga più comune non è incollare la chiave nel codice: è il prefisso della variabile d'ambiente. In Astro, `import.meta.env.PUBLIC_X` viene INLINEato nel bundle del client a build-time; `import.meta.env.X` (senza `PUBLIC_`) esiste solo nel contesto server/build e NON viene esposto. Vite/Astro fanno questo per design. L'errore classico: chiamare una chiave `PUBLIC_SUPABASE_SERVICE_KEY` 'perché il componente la veda'. Ci riesci — e la regali al mondo. Il tuo albero decisionale per nominare una env var: questa chiave può essere pubblica? Sì → `PUBLIC_`. No → senza prefisso, e per giunta chiediti cosa ci fa in un progetto SSG (probabilmente non dovrebbe esserci).
Costruisci l'inventario. Apri il tuo progetto e classifica ogni credenziale in una tabella a tre colonne: nome · classe (pubblica/privata) · dove vive oggi. In MOONKEY le voci reali sono: `PUBLIC_SUPABASE_URL` (pubblica, identificatore), `PUBLIC_SUPABASE_ANON_KEY` (pubblica, limitata da RLS), e la `service_role` (privata — NON deve esistere affatto in questo repo; se ti serve per uno script di migrazione, vive nella tua shell locale o in un secret di CI, mai in `src/`). Aggiungi qualsiasi token di terze parti che tocchi: chiavi di email transazionale, webhook, token di analytics server-side. Ogni riga privata che si trova dal lato sbagliato è un incidente in attesa di accadere.
Il controllo che chiude il ciclo è la rilevazione automatica, perché la disciplina umana fallisce. Prima di ogni commit vuoi che qualcosa urli se una chiave privata si infila. Il pattern minimo: un grep in pre-commit che cerchi il pattern di una `service_role` (i JWT di Supabase service portano `"role":"service_role"` nel payload base64) e parole come `service_role`, `BEGIN PRIVATE KEY`, `sk-`. Per i repo reali usa `gitleaks` o `trufflehog` come hook. E se una chiave è GIÀ finita in un commit passato: non basta cancellarla dal codice — resta nella storia di git. Bisogna RUOTARLA nel pannello di Supabase (Settings → API → roll key) e riscrivere la storia se era un segreto vero. Ruotare è obbligatorio; cancellare il file non revoca nulla.
Chiudi con il caso Espejo, che aggiunge un asse etico, non solo tecnico. Espejo è multi-tenant: ogni influencer-tenant ha i suoi dati. Lì la domanda 'pubblica vs privata' si sdoppia: oltre alle chiavi di infrastruttura, gestisci dati personali/consentiti di utenti finali. La chiave di servizio capace di leggere ACROSS tenant è il segreto più sensibile del prodotto — la sua fuga non è solo tecnica, viola la frontiera di dati che la memoria di progetto marca come innegoziabile. Lezione trasversale: una credenziale privata non si definisce dal suo formato, si definisce da ciò che sblocca. Misurala sempre dal suo blast radius.
ESERCIZIO
Nel tuo progetto (usa MOONKEY o uno tuo): 1) Crea `SECRETS.md` con la tabella di inventario (nome · classe · posizione · blast radius) per ogni credenziale che il progetto tocca. 2) Esegui `grep -rn "service_role\|BEGIN.*PRIVATE KEY\|sk-" src/ .env* 2>/dev/null` e documenta cosa ha trovato (idealmente nulla in `src/`). 3) Scrivi un hook `.git/hooks/pre-commit` che faccia quel grep sui file staged (`git diff --cached --name-only`) e abortisca con exit 1 se c'è un match. 4) Verifica l'hook provando a committare un file di prova con la stringa `"role":"service_role"` e conferma che lo blocca.
CONSEGNA
`SECRETS.md` con la tabella di inventario completa (ogni credenziale classificata per classe e blast radius) + un hook `.git/hooks/pre-commit` eseguibile che blocca i commit con segreti privati, testato contro un caso che deve fallire.
INTUIZIONE CHIAVE
Una credenziale non è segreta per come appare, ma per ciò che sblocca: la anon key e la service_role sono entrambe JWT identici a vista, e una è pubblica per design mentre l'altra compromette tutta la galassia. Classifica sempre per blast radius, mai per formato.
ERRORI DA EVITARE
- ×Mettere il prefisso `PUBLIC_` a una chiave privata 'perché il componente la legga' — Astro la inlinea nel bundle e la pubblica al mondo.
- ×Credere che cancellare la chiave dal file la revochi: resta nella storia di git e resta attiva finché non la RUOTI nel pannello del provider.
- ×Trattare la anon key come un segreto e offuscarla: perdi tempo a proteggere qualcosa di pubblico mentre l'autorità reale (RLS) resta non verificata.
- ×Dare per scontato che un sito SSG 'non ha segreti esposti' senza verificare il bundle generato: ciò che conta è cosa c'è in `dist/`, non cosa c'è in `src/`.
- ×Mettere la service_role in uno script di migrazione versionato nel repo invece di leggerla dalla shell locale o da un secret di CI a runtime.
OS-02 Threat model di una SSG
lezione Pensare come l'attaccante di un sito statico con anon key: enumerare la superficie d'attacco reale di una SSG + Supabase e capire perché l'unica difesa che conta è RLS, non il client.
Threat model di una SSG
lezionePensare come l'attaccante di un sito statico con anon key: enumerare la superficie d'attacco reale di una SSG + Supabase e capire perché l'unica difesa che conta è RLS, non il client.
Molti proteggono la porta sbagliata: nascondono il pannello admin nel frontend e lasciano il DB aperto. Un sito statico non ha un server che validi nulla — se il tuo modello di minaccia non parte da 'il client è ostile e controllato dall'attaccante', costruirai difese decorative che si scavalcano con curl.
LA LEZIONE
Adotta il giusto quadro mentale: in una SSG, il client non è la tua applicazione, è territorio nemico. L'attaccante ha il tuo bundle completo (è pubblico), il tuo `PUBLIC_SUPABASE_URL`, la tua anon key, e sa che dietro c'è Postgres. Non ha bisogno di 'hackerare' il tuo JavaScript: lo legge. Può istanziare il proprio client Supabase con la TUA anon key da una console di Node e parlare diretto con il tuo DB, senza passare mai dal tuo HTML. Perciò ogni logica di sicurezza che vive in `.astro` o in JS di client — un `if (user.role === 'admin')`, un componente che si nasconde — è UX, non sicurezza. Il CLAUDE.md di MOONKEY lo dice letterale: `admin.astro` è UX; la sicurezza NON dipende da quel gate.
Enumera la superficie d'attacco concreta. Per MOONKEY ci sono cinque tabelle raggiungibili con la anon key: `profiles, progress, feedback, leads, proofs`. L'attaccante, con un client autenticato via magic-link (chiunque può registrarsi), proverà l'ovvio: `supabase.from('profiles').select('*')` per scaricare TUTTI i profili; `supabase.from('leads').select('*')` per rubare la lista di lead; un `update` sulla propria riga di `profiles` mettendo `role = 'admin'` o `founder_badge = true` per auto-scalare; un `insert` in `proofs` con `user_id` altrui per falsificare il progresso di un altro. Ognuna di queste è un'ipotesi d'attacco che TU devi confutare con una policy. Se non l'hai confutata esplicitamente, dai per scontato che funzioni.
Capisci perché RLS è l'autorità e come si compone la difesa reale di MOONKEY. SELECT su `profiles/progress/feedback/proofs` è self-or-admin: la policy permette la riga solo se `user_id = auth.uid()` O `is_admin()`. `is_admin()` è SECURITY DEFINER — gira con i permessi del proprietario, verifica se il chiamante è admin senza che il chiamante possa manipolare quella decisione. Risultato: un non-admin che chiede `select('*')` non riceve un errore, riceve LE SUE righe e nient'altro — il dump massivo restituisce una sola riga, la sua. L'escalation di privilegi la taglia un trigger (`guard_privileged_profile_columns`): `role` e `founder_badge` sono immutabili per i non-admin, quindi l'`update role='admin'` viene rifiutato a livello di DB. L'insert altrui lo taglia `proofs_insert_self`: esige sessione e lega `user_id = auth.uid()`.
La conseguenza operativa del modello: verifica per impersonazione, non per lettura del codice. Una policy che 'sembra' corretta può avere un buco (un OR mal posto, un USING senza WITH CHECK che lascia passare gli update). L'unica prova che vale è metterti nei panni dell'attaccante: autenticati come un utente normale ed ESEGUI gli attacchi enumerati sopra contro il DB reale, confermando che ognuno restituisce vuoto o errore. Il CLAUDE.md menziona che l'isolamento di lettura è 'verificato per impersonazione' — questo è lo standard. 'Ho scritto la policy' non è prova; 'ho provato l'attacco come utente X e non ha letto la riga dell'utente Y' lo è.
Non dimenticare i vettori che non sono RLS. Il modello di minaccia di una SSG include anche: (a) la differenza USING vs WITH CHECK — USING filtra quali righe vedi/colpisci, WITH CHECK valida lo stato risultante di un INSERT/UPDATE; dimenticare WITH CHECK in un UPDATE ti lascia spostare una riga a un user_id altrui. (b) Le RPC: una funzione SECURITY DEFINER scritta male è una falla che ignora RLS per design — MOONKEY le restringe ad `authenticated` e revoca le operazioni pericolose. (c) TRUNCATE: revocato da anon/authenticated, perché RLS non protegge contro TRUNCATE (cancella l'intera tabella senza valutare le policy riga per riga). (d) Fuga per errori e per colonne: un SELECT che restituisce una colonna sensibile che hai dimenticato di escludere.
Atterra con il contrasto tra progetti per fissare il principio. XNLAB è un sito di brand quasi senza backend: la sua superficie è minima — moduli di contatto, intestazioni, forse un endpoint di acquisizione. Lì il modello di minaccia è 'spam e XSS nel poco che accetta input', non escalation di DB. XHUB IRON vive nella STESSA DB di MOONKEY ma è un pannello operativo del fondatore: le sue tabelle `iron_*` non devono essere leggibili da un utente di MOONKEY — lo stesso motore RLS deve mantenere DUE prodotti che condividono Postgres senza che uno legga l'altro (è OS-05). Il modello di minaccia non è generico: si deriva da QUALI dati ci sono, CHI può autenticarsi, e COSA condividono dell'infrastruttura. Parti sempre dall'inventariare quei tre assi.
ESERCIZIO
Su MOONKEY (o il tuo progetto Supabase): 1) Scrivi `THREAT-MODEL.md` elencando, per ogni tabella raggiungibile con anon key, gli attacchi che un utente autenticato ostile proverebbe (dump massivo, escalation di privilegi, scrittura su riga altrui). 2) Per ogni attacco, annota la difesa che DOVREBBE fermarlo (quale policy/trigger) e marcala come 'verificata' o 'non verificata'. 3) Scrivi lo snippet di impersonazione che eseguiresti per provare il dump di `profiles` come utente normale (client con anon key + sessione di un utente di prova che fa `.from('profiles').select('*')`) e prevedi il risultato atteso (solo 1 riga).
CONSEGNA
`THREAT-MODEL.md`: una tabella attacco→difesa→stato-di-verifica che copre ogni tabella esposta dalla anon key, più lo snippet di impersonazione concreto per almeno un attacco di dump massivo con il suo risultato atteso.
INTUIZIONE CHIAVE
In una SSG il client è territorio dell'attaccante: ha il tuo bundle, la tua anon key e parla diretto con il tuo Postgres via curl senza toccare il tuo HTML. Ogni controllo di sicurezza che vive in .astro è UX; l'unica frontiera reale è RLS, e conta solo quando l'hai confutata per impersonazione, non leggendola.
ERRORI DA EVITARE
- ×Confondere nascondere il pannello admin nel frontend con il proteggerlo: `admin.astro` nasconde la UI, ma il DB resta accessibile via curl con la anon key.
- ×Verificare le policy leggendole invece di attaccarle come utente impersonato — un OR mal posto si vede solo eseguendo l'attacco.
- ×Scrivere un UPDATE con USING ma senza WITH CHECK, permettendo di riassegnare la riga a un `user_id` altrui anche se 'sembra' che la policy protegga.
- ×Dimenticare che TRUNCATE e le RPC SECURITY DEFINER aggirano RLS: proteggerle esige REVOKE esplicito, non basta avere policy di riga.
- ×Applicare un modello di minaccia generico copiato da un tutorial invece di derivarlo dai tuoi tre assi reali: quali dati, chi autentica, quale infrastruttura condivide.
OS-03 Audit avversariale
lezione Verificare in modo avversariale: trattare ogni risultato di sicurezza come un'ipotesi da confutare prima di crederci, invece di accettare 'sembra vulnerabile' o 'sembra sicuro' per ispezione.
Audit avversariale
lezioneVerificare in modo avversariale: trattare ogni risultato di sicurezza come un'ipotesi da confutare prima di crederci, invece di accettare 'sembra vulnerabile' o 'sembra sicuro' per ispezione.
Il bias di conferma rovina le verifiche: uno scanner dice 'RLS disattivato' ed entri in panico, oppure tu dici 'la policy sembra a posto' e firmi l'ok. Entrambi gli errori costano caro — i falsi positivi bruciano il tuo tempo e la tua credibilità; i falsi negativi lasciano brecce aperte. La disciplina di confutare per prima cosa è ciò che separa una verifica reale da un'opinione.
LA LEZIONE
Il principio guida è popperiano: un risultato di sicurezza non è vero perché lo affermi, è vero perché hai PROVATO a confutarlo e non ci sei riuscito. Inverti il flusso naturale. Quando credi di aver trovato una vulnerabilità ('chiunque può leggere `leads`'), il tuo passo successivo non è segnalarla — è provare a dimostrare che ti sbagli: c'è una policy che lo impedisce? L'ho provato con il ruolo corretto? Il `select` ha restituito dati reali o vuoto? Solo quando il tuo tentativo di confutazione fallisce, il risultato sale a confermato. E viceversa: quando credi che qualcosa sia sicuro ('la policy self-or-admin protegge `profiles`'), il tuo lavoro è attaccarlo finché non si rompe o sopravvive.
Definisci i due errori che insegui con nomi chiari. Falso positivo: segnali una vulnerabilità che non esiste — es. urli 'la anon key è esposta nel bundle' quando la anon key è pubblica per design e il DB è protetto da RLS (vedi OS-01). Bruci fiducia; la prossima volta nessuno ti crede. Falso negativo: dai per sicuro qualcosa che non lo è — es. 'il pannello admin è nascosto, siamo a posto' senza provare l'accesso diretto al DB. Lasci la breccia aperta. La verifica avversariale esiste per minimizzare ENTRAMBI, e lo strumento per entrambi è lo stesso: la prova eseguibile.
Il metodo concreto: per ogni risultato, produci una prova riproducibile, non un'affermazione. 'Riproducibile' significa: un comando o snippet che chiunque può eseguire vedendo lo stesso risultato. Per confermare che `profiles` NON si può dumpare: uno script che si autentica come user_A, fa `.from('profiles').select('*')`, e stampa il conteggio di righe — atteso 1 (solo la sua). Per confermare che l'escalation di privilegi è chiusa: come user_A, `.from('profiles').update({ role: 'admin' }).eq('id', myId)` e stampa l'errore che restituisce il trigger guard. Se l'attacco restituisce ciò che ti aspettavi (vuoto/errore), il controllo è verificato. Se restituisce dati, hai un risultato REALE — e viene già con la sua PoC allegata.
Lavora con lo strumento di Supabase ma senza arrenderti ad esso. `get_advisors` (security lints) ti segnala tabelle senza RLS, policy permissive, funzioni SECURITY DEFINER senza search_path fisso. Trattalo come generatore di IPOTESI, non di verdetti. Un advisor che dice 'funzione X è SECURITY DEFINER con search_path mutabile' è un'ipotesi di rischio: vai alla funzione, guarda se un attaccante può piazzare un oggetto in uno schema che essa risolve per prima. A volte è sfruttabile, a volte no per via del contesto. L'advisor ti risparmia la scansione iniziale; la confutazione la fai tu. Il CLAUDE.md già lo istituzionalizza: cambi di RLS = migrazione versionata + `get_advisors` dopo. Quella seconda parte è la verifica avversariale resa routine.
Bada ai falsi negativi del metodo stesso: l'attacco che 'passa' può passare per la ragione sbagliata. Se il tuo `select('*')` su `profiles` restituisce vuoto, è perché RLS l'ha bloccato, o perché la tua sessione di prova è scaduta / non eri autenticato / la tabella era vuota? Un controllo non si dà mai per buono senza un caso di CONTRASTO: prova che la stessa query SÌ restituisce dati quando deve (come admin, o come proprietario della riga). Una prova di sicurezza senza il suo controllo positivo è un falso negativo travestito da successo. Verifica sempre che la tua arma funzioni prima di concludere che il bersaglio è immune.
Atterra il rigore nei progetti dove sbagliare fa più male. In XCAP la verifica avversariale è dottrina centrale: la memoria del progetto la descrive come 'anti-fabbricazione per costruzione' — un sistema che registra forecast e li calibra contro risultati reali, confutando le proprie previsioni invece di crederci. Quello stesso muscolo lo applichi alla sicurezza: non firmare 'il ledger è read-only' finché un tentativo di scrittura con la credenziale di ingest non fallisce davanti ai tuoi occhi. In Espejo, dove la frontiera di dati personali è etica oltre che tecnica, una verifica che dice 'non c'è fuga tra tenant' senza una PoC di un tenant che prova a leggerne un altro NON è una verifica: è un desiderio. Confutare per primo, sempre, specialmente quando il risultato che ti aspetti è quello comodo.
ESERCIZIO
Prendi un controllo di sicurezza reale di MOONKEY (es. 'un non-admin non può leggere profili altrui'). 1) Formulalo come ipotesi confutabile. 2) Scrivi DUE prove: quella dell'attacco (come user_A provo a leggere la riga di user_B → mi aspetto vuoto) e quella di contrasto (come user_A leggo LA MIA riga → mi aspetto 1 riga; o come admin leggo tutte → mi aspetto N). 3) Esegui `get_advisors` (o documenta cosa segnalerebbe) e per ogni lint classificalo come 'ipotesi confermata come rischio reale' o 'confutata (non sfruttabile perché…)'. 4) Scrivi un mini-report dove ogni affermazione è accompagnata dal suo comando riproducibile.
CONSEGNA
`AUDIT.md`: un report dove ogni risultato (vulnerabile o sicuro) porta la sua PoC riproducibile — attacco + caso di contrasto positivo — e dove ogni lint di `get_advisors` è marcato come confermato o confutato con la sua ragione.
INTUIZIONE CHIAVE
Una prova di sicurezza che 'passa' senza caso di contrasto positivo è un falso negativo travestito: il select vuoto può dipendere da RLS o da una sessione scaduta. Verifica sempre che la tua arma spari prima di dichiarare immune il bersaglio — confutare per primo vale anche per il tuo stesso metodo.
ERRORI DA EVITARE
- ×Segnalare un risultato per ispezione del codice senza una PoC eseguibile: 'la policy sembra debole' non è un risultato, è un'intuizione.
- ×Trattare i lint di `get_advisors` come verdetti invece che come ipotesi da confutare — generano falsi positivi che, ripetuti, bruciano la tua credibilità.
- ×Dare per sicuro un controllo perché l'attacco ha restituito vuoto, senza provare che la stessa query SÌ restituisce dati quando deve (controllo positivo).
- ×Urlare 'anon key esposta' come vulnerabilità quando è pubblica per design: il falso positivo classico che tradisce chi non ha capito il modello di sicurezza.
- ×Verificare solo nella direzione comoda (confermare ciò che ti aspetti) invece di attaccare con la stessa forza i controlli che credi sicuri.
OS-04 Incident response e rollback
lezione Rispondere a un incidente in produzione con una procedura a freddo: rilevare, contenere, fare rollback in modo sicuro, e condurre un post-mortem senza colpa che chiuda la causa radice.
Incident response e rollback
lezioneRispondere a un incidente in produzione con una procedura a freddo: rilevare, contenere, fare rollback in modo sicuro, e condurre un post-mortem senza colpa che chiuda la causa radice.
Quando qualcosa si rompe in produzione, l'istinto (improvvisare, toccare il DB a caldo, nascondere l'errore) di solito peggiora il danno. La differenza tra uno spavento di dieci minuti e un weekend perso è avere un runbook scritto PRIMA dell'incidente. Senza, un rollback fatto male distrugge dati che il bug aveva solo corrotto.
LA LEZIONE
Interiorizza la sequenza DCRP: Rilevare → Contenere → Recuperare (rollback) → Post-mortem. L'errore dei più è saltare direttamente a 'sistemare', toccando la produzione alla cieca. L'ordine conta perché ogni fase protegge la successiva: se non rilevi bene, contieni ciò che non è; se non contieni, il danno cresce mentre recuperi; se recuperi senza capire, ripeti l'incidente. E la regola zero, scritta in qualsiasi runbook serio: in un incidente NON improvvisi sulla produzione. Esegui passi predefiniti. La creatività è per il post-mortem, non per le 3 di notte con il sito giù.
Rilevare è sapere COSA è rotto e da QUANDO. In MOONKEY (Cloudflare Pages + Supabase) i tuoi segnali sono: il deploy di Cloudflare (quale commit è live? l'ultimo build è passato?), i log di Supabase (`get_logs` per servizio: api, postgres, auth — lì vedi errori di policy, query che falliscono, picchi di 4xx/5xx), e la lamentela dell'utente (spesso il primo segnale). La prima cosa che stabilisci è la timeline: cosa è cambiato proprio prima? Quasi sempre l'incidente correla con un deploy o una migrazione recente. 'È iniziato dopo il merge delle 16:20' restringe lo spazio di ricerca da ore a minuti.
Contenere è fermare l'emorragia prima di curare la ferita. Domanda chiave: questo sta corrompendo dati ADESSO? Se un bug sta scrivendo righe sbagliate in `proofs` o `progress`, ogni minuto che impieghi moltiplica il cleanup. Opzioni di contenimento secondo la gravità: ripristinare il deploy di Cloudflare al precedente (un click — Cloudflare Pages conserva i deploy precedenti e permette un rollback istantaneo, questa è la tua leva più rapida e sicura), disabilitare la feature rotta, o nel caso estremo di scrittura distruttiva, tagliare l'accesso a livello di policy. Contenere NON è sistemare: è congelare il danno nel suo stato attuale per avere tempo di pensare.
Recuperare esige di distinguere DUE rollback che la gente confonde e che hanno rischi opposti. (1) Rollback di CODICE: tornare a un deploy/commit precedente. È economico, reversibile e quasi sempre sicuro — in Cloudflare Pages è immediato. Questa è la tua prima opzione. (2) Rollback di SCHEMA/DATI: revertire una migrazione o ripristinare dati. È PERICOLOSO e irreversibile: un `down` migration che fa `DROP COLUMN` cancella dati che forse erano solo corrotti, non persi. Regole: non eseguire mai un rollback di dati senza un backup/PITR confermato prima; preferisci una migrazione FORWARD che corregge (un nuovo `UPDATE` che ripara le righe sbagliate) a un `down` distruttivo; ogni migrazione di Supabase è versionata (`apply_migration`) proprio perché lo stato sia ricostruibile. Se il dubbio è 'rollback di codice o di dati', parti SEMPRE da quello di codice: spesso basta e non tocca il DB.
Il post-mortem è dove l'incidente paga il suo debito trasformandosi in apprendimento. È SENZA COLPA (blameless): l'obiettivo non è chi ha rotto, ma cosa del SISTEMA ha permesso a un umano normale di rompere. Struttura: timeline (cosa è successo e quando, minuto per minuto), impatto (a quanti utenti, quali dati, per quanto tempo), causa radice (i '5 perché' fino ad arrivare al guasto di sistema, non di persona), e azioni correttive con responsabile e data. Ogni incidente deve produrre almeno un controllo che lo avrebbe PREVENUTO o RILEVATO prima — un test, un advisor in CI, un alert. Se il post-mortem non cambia nulla del sistema, non era un post-mortem, era una confessione.
Ancora il runbook alla realtà di ogni progetto, perché la gravità e le leve cambiano. In XCAP esiste un'invariante di capitale sacra — il capitale si accumula tra sessioni e si muove solo nei chiusi realizzati, mai si resetta. Un incidente che tocchi quell'invariante NON si sistema con un rollback di dati alla cieca: cancelleresti storia contabile reale. Lì il runbook obbliga a correzione forward e verifica dell'invariante prima di toccare qualsiasi cosa. In Espejo, multi-tenant, un incidente ha una domanda in più nella fase di rilevazione: il guasto ha attraversato la frontiera tra tenant? Se un bug ha esposto dati di un tenant a un altro, oltre al rollback c'è obbligo di contenimento della fuga. Il runbook generico ti dà la spina dorsale; gli invarianti di ogni progetto ti danno le regole che NON violi MAI durante il panico.
ESERCIZIO
Scrivi il `RUNBOOK.md` degli incidenti per MOONKEY: 1) La sezione Rilevare (quali comandi/pannelli guardi: deploy di Cloudflare, `get_logs` di Supabase per servizio, come stabilisci la timeline). 2) La sezione Contenere con la leva di rollback di Cloudflare Pages passo per passo. 3) Un albero decisionale 'rollback di codice vs rollback di dati' con la regola 'parti dal codice, dati solo con PITR confermato'. 4) Un template di post-mortem blameless (timeline / impatto / 5-perché / azioni correttive con responsabile). 5) Simula un incidente: 'dopo il deploy delle 16:20, gli utenti non possono salvare i proofs' e scrivi la risposta passo per passo seguendo il tuo stesso runbook.
CONSEGNA
`RUNBOOK.md` con le quattro fasi DCRP operazionalizzate per Cloudflare Pages + Supabase, l'albero decisionale codice-vs-dati, un template di post-mortem blameless, e un incidente simulato risolto end-to-end con la tua stessa procedura.
INTUIZIONE CHIAVE
Ci sono due rollback con rischi opposti: quello di codice è economico e reversibile (parti sempre da lì), quello di dati è distruttivo e irreversibile. Nel dubbio, una migrazione forward che ripara righe corrotte è quasi sempre meglio di un `down` che le cancella — perché spesso i dati erano danneggiati, non persi.
ERRORI DA EVITARE
- ×Saltare direttamente a 'sistemare' toccando la produzione alla cieca invece di seguire Rilevare→Contenere→Recuperare: l'improvvisazione alle 3 di notte è il modo in cui uno spavento diventa un disastro.
- ×Fare un rollback di dati (down migration con DROP) senza backup/PITR confermato, distruggendo dati che il bug aveva solo corrotto.
- ×Non stabilire la timeline prima di agire: senza sapere quale deploy o migrazione ha innescato l'incidente, cerchi la causa in tutto il sistema invece che negli ultimi cambi.
- ×Confondere contenere con sistemare: lasciare il bug a scrivere righe sbagliate mentre 'indaghi' moltiplica il cleanup successivo.
- ×Chiudere l'incidente con un post-mortem che cerca un colpevole invece di un guasto di sistema, e che non produce nemmeno un controllo nuovo che lo avrebbe prevenuto.
OS-05 Isolamento tra progetti
lezione Far sì che diversi progetti condividano un unico database Postgres senza che uno possa leggere, scrivere o rompere i dati di un altro — isolamento multi-progetto e multi-tenant imposto dal DB, non dalla convenzione.
Isolamento tra progetti
lezioneFar sì che diversi progetti condividano un unico database Postgres senza che uno possa leggere, scrivere o rompere i dati di un altro — isolamento multi-progetto e multi-tenant imposto dal DB, non dalla convenzione.
Condividere un'istanza Supabase tra progetti risparmia costo ma crea il rischio più silenzioso della galassia: che il client di MOONKEY, con la sua anon key, legga le tabelle di XHUB IRON. L'isolamento basato su 'ricordati di non toccare quelle tabelle' è carta straccia; un giorno qualcuno le tocca. L'isolamento reale è strutturale e si verifica attaccandolo.
LA LEZIONE
Parti dal fatto concreto: l'istanza `wuchsslgbqlhyxljsmxi` ospita DUE prodotti. MOONKEY usa `profiles, progress, feedback, leads, proofs`. XHUB IRON usa `iron_*, world_*, focus_*, daily_focus_history`. Condividono lo stesso Postgres, lo stesso `auth.users`, e — punto chiave — la STESSA anon key si usa per parlare con il DB. Questo significa che il client pubblico di MOONKEY tecnicamente PUÒ provare `from('iron_command').select('*')`. L'unica ragione per cui non funziona deve essere una barriera dura nel DB, non la nota del CLAUDE.md che dice 'non toccarle'. Quella nota protegge dal fatto che TU le modifichi per errore scrivendo codice; non protegge da un attaccante che ha già la tua anon key.
Capisci i livelli di isolamento dal più debole al più forte. (1) Convenzione (nomi `iron_*` vs senza prefisso): organizza, non isola — un attaccante ignora le convenzioni. (2) RLS per tabella: ogni tabella di XHUB deve avere policy che concedano accesso solo al proprio ruolo/utente; una tabella `iron_*` senza RLS o con una policy permissiva è una fuga diretta. (3) Schema separati: mettere XHUB nel proprio schema Postgres e NON esporlo nell'API di PostREST (il `db.schema` che Supabase pubblica) è una barriera più forte — ciò che non è nello schema esposto, il client nemmeno lo vede. (4) Ruoli/grant: REVOKE da `anon`/`authenticated` sulle tabelle/oggetti altrui. La difesa robusta IMPILA questi livelli; non fidarti di uno solo.
Per il caso MOONKEY↔XHUB, la domanda di verifica è diretta: un utente autenticato di MOONKEY può leggere una riga di `iron_*`? Confutalo (OS-03): autenticati come utente normale di MOONKEY ed esegui `supabase.from('iron_command').select('*')`. Il corretto è che restituisca errore di permesso o vuoto per via di RLS. Se restituisce dati, hai una fuga di isolamento critica. La memoria di progetto dice che la salute è computata e che il modello è 'open RLS, computed health' per l'HUB — questo significa che devi verificare ESPLICITAMENTE che 'open' per l'HUB non significhi 'aperto ai client di MOONKEY'. Isolamento tra progetti = ogni tabella risponde solo al suo proprietario legittimo, provato per impersonazione incrociata.
Il secondo asse è il multi-tenant DENTRO un progetto, ed è Espejo. Qui non sono due prodotti, sono N tenant (ogni influencer) nelle stesse tabelle. L'isolamento diventa per-riga: ogni riga porta un `tenant_id`, e la policy RLS esige che `tenant_id` coincida con il tenant del chiamante. L'attacco da confutare: il tenant A prova a leggere/scrivere righe con il `tenant_id` di B. La trappola classica è l'UPDATE senza WITH CHECK: USING limita quali righe A vede, ma senza WITH CHECK, A potrebbe riassegnare una riga a B o crearne una con `tenant_id` altrui. Un'altra trappola: una RPC SECURITY DEFINER che riceve `tenant_id` come parametro e non valida che coincida con `auth.uid()` — falla che attraversa tutti i tenant. L'isolamento multi-tenant è reale solo se OGNI policy di ogni operazione (SELECT/INSERT/UPDATE/DELETE) ancora il tenant all'identità del chiamante, non a un parametro che il client controlla.
Ci sono vettori di incrocio che non sono SELECT e che la gente dimentica. `auth.users` è condiviso: un utente è lo stesso in MOONKEY e in XHUB — assicurati che avere un account in uno non conceda ruoli nell'altro (in MOONKEY, `role` e `founder_badge` sono immutabili per i non-admin proprio perché registrarsi non scali privilegi incrociati). Le RPC e le funzioni SECURITY DEFINER sono oggetti globali: una funzione di XHUB eseguibile da `authenticated` è invocabile dal client di MOONKEY — restringila per ruolo o valida il contesto al suo interno. Trigger e sequenze condivise, viste che fanno JOIN tra tabelle di entrambi i prodotti, e `get_advisors` che segnala RLS mancante in qualsiasi tabella del progetto: tutto questo è superficie di incrocio. L'isolamento non è una policy, è una proprietà del sistema intero.
Chiudi con la disciplina operativa che mantiene vivo l'isolamento nel tempo. Ogni cambio di schema entra come migrazione versionata (`apply_migration`), mai come modifica manuale nel pannello — così lo stato di sicurezza è ricostruibile e verificabile. Dopo ogni migrazione che tocchi tabelle o policy, esegui `get_advisors` per cacciare RLS disattivato o policy permissive introdotte senza volerlo. E mantieni un test di isolamento come parte del runbook: uno script che, come utente di MOONKEY, prova a leggere ogni prefisso altrui (`iron_*`, `world_*`) e fa fallire il build se qualcuno restituisce righe. L'isolamento tra progetti non si 'configura una volta'; si ri-verifica a ogni cambio, perché una migrazione innocente può aprire una porta che era chiusa da mesi.
ESERCIZIO
Sul DB condiviso `wuchsslgbqlhyxljsmxi` (o una replica di prova): 1) Elenca in `ISOLATION.md` le tabelle di ogni prodotto (MOONKEY vs XHUB) e il livello di isolamento che protegge ognuna (RLS / schema non esposto / REVOKE). 2) Scrivi il test di impersonazione incrociata: come utente normale di MOONKEY, prova `select('*')` su `iron_command` (o qualsiasi `iron_*`/`world_*`) e documenta il risultato atteso (errore/vuoto). 3) Per il caso multi-tenant di Espejo, scrivi le quattro policy (SELECT/INSERT/UPDATE/DELETE) di una tabella con `tenant_id`, assicurando che UPDATE porti WITH CHECK e che nessuna si fidi di un `tenant_id` passato dal client. 4) Documenta come `get_advisors` entra nella tua routine post-migrazione.
CONSEGNA
`ISOLATION.md`: la mappa tabelle→livello-di-isolamento per i due prodotti del DB condiviso, un test eseguibile di impersonazione incrociata MOONKEY→XHUB con risultato atteso, e il set di quattro policy RLS per-tenant di Espejo con WITH CHECK corretto sulle operazioni di scrittura.
INTUIZIONE CHIAVE
Condividere Postgres tra progetti è sicuro solo quando l'isolamento è strutturale (RLS + schema non esposto + REVOKE), non documentale: la nota 'non toccare quelle tabelle' protegge dai tuoi errori di codice, mai da un attaccante che ha già la tua anon key ed enumera prefissi altrui.
ERRORI DA EVITARE
- ×Affidare l'isolamento a convenzioni di nomi o a una nota nel CLAUDE.md: un attaccante con la anon key ignora entrambe e prova `from('iron_*').select('*')` direttamente.
- ×Lasciare una tabella altrui (`iron_*`, `world_*`) senza RLS o con policy permissiva in un DB la cui anon key è condivisa da più progetti — fuga diretta tra prodotti.
- ×In multi-tenant, scrivere UPDATE/INSERT con USING ma senza WITH CHECK, permettendo a un tenant di riassegnare o creare righe con il `tenant_id` di un altro.
- ×Costruire una RPC SECURITY DEFINER che si fida di un `tenant_id` (o `user_id`) passato come parametro dal client invece di derivarlo da `auth.uid()`.
- ×Trattare l'isolamento come configurazione una tantum e non ri-eseguire `get_advisors` dopo ogni migrazione: un cambio innocente può riaprire una porta chiusa da mesi.
OS-06 Observabilidad y coste
lezione Montare un pannello di osservabilità e un budget con alert per un sistema vivo (Astro su Cloudflare Pages + Supabase): definire quali log e metriche sorvegliare, cosa fa scattare un alert, e quali tetti di uso e di spesa in token ti avvisano PRIMA che arrivi la fattura a sorpresa.
Observabilidad y coste
lezioneMontare un pannello di osservabilità e un budget con alert per un sistema vivo (Astro su Cloudflare Pages + Supabase): definire quali log e metriche sorvegliare, cosa fa scattare un alert, e quali tetti di uso e di spesa in token ti avvisano PRIMA che arrivi la fattura a sorpresa.
Lanciare non è finire: è il momento in cui inizi a operare. Un sistema in produzione genera tre flussi che quasi nessuno guarda finché non fanno male: log (cosa sta succedendo), errori (cosa si rompe senza che nessuno te lo dica) e costo (quanto ti addebitano per restare vivo). MOONKEY LAB, XHUB, Espejo e XCAP condividono infrastruttura reale con piani free/pro e quote reali di Supabase e Cloudflare, e tutto l'uso di IA si fattura per token. L'operatore che non monitora scopre i problemi per due vie, entrambe care: un utente che si lamenta, o un addebito sulla carta. L'osservabilità trasforma 'non so cosa succede in produzione' in 'ho una dashboard che me lo dice'. Il costo è sicurezza: un endpoint aperto che qualcuno martella non è solo un buco, è una fattura. Questa è l'ultima disciplina della costellazione perché chiude il ciclo: hai costruito, hai messo in sicurezza, ora operi a occhi aperti.
LA LEZIONE
I tre flussi di un sistema vivo. Log = il diario di cosa accade (richieste, query, auth). Errori = ciò che si rompe in silenzio dal lato client, dove i tuoi log di server non arrivano. Costo = il contatore che corre anche se nessuno usa il sistema. Sono tre tubature distinte con tre strumenti distinti; confonderle è il motivo per cui la gente crede di stare monitorando e non lo fa.
L'osservabilità in una SSG non è la stessa cosa che in un server. MOONKEY è statico: NON c'è un handler server-side dove mettere un logger. La tua telemetria vive in tre posti estranei al tuo HTML: (1) Cloudflare Pages Analytics (request, banda, errori di edge), (2) i log di Supabase (Dashboard → Logs: API, Postgres, Auth — ogni query che passa per PostREST e ogni login lascia traccia), e (3) il browser dell'utente (errori di JS che solo tu non vedi). Accettalo: in una SSG, gran parte di ciò che fallisce avviene su una macchina che non controlli.
Cosa sorvegliare davvero, non tutto. La tentazione è loggare tutto e non leggere niente. Definisci una manciata di segnali che contano: tasso di errore 4xx/5xx all'edge (un picco di 401/403 = RLS che rifiuta o un attaccante che prova), errori di Auth in Supabase (tentativi falliti di magic-link), latenza delle query lente (Supabase segna quelle che tardano), e volume di righe lette (una query senza filtro che di colpo restituisce 10.000 righe è un bug di costo). Una metrica che non innesca nessuna decisione è rumore; cancellala.
Error tracking dal lato client: il punto cieco. In un sito statico, un TypeError nel tuo JS rompe l'esperienza dell'utente e a te non arriva NULLA — né log di server né alert. Devi catturare gli errori nel browser e mandarli fuori. Il minimo onesto: un window.addEventListener('error', ...) e ('unhandledrejection', ...) che faccia un fetch a un endpoint tuo (una Edge Function di Supabase che inserisce in una tabella di log con RLS di solo-insert). Il robusto: Sentry o simili con il suo SDK gratuito fino a un certo volume. La regola: se non catturi gli errori di client, stai operando alla cieca sulla metà del tuo sistema.
Il costo reale di operare, scomposto. Il free tier di Supabase ha tetti concreti che, una volta superati, o ti tagliano o ti addebitano: righe di DB, banda di egresso, storage, Monthly Active Users di Auth, e invocazioni di Edge Functions. Cloudflare Pages è generoso ma ha limiti di build/mese e di request nelle funzioni. E la spesa che più scala e meno si vede: i token di IA. Ogni chiamata all'API di Claude si fattura per token di input e di output, con tariffe diverse; un prompt con un CLAUDE.md gigantesco o un loop di agente senza tetto può moltiplicare il costo senza che cambi 'ciò che fa' la app. L'operatore conosce le sue tre fatture: DB, edge e token.
Perché arrivano le fatture a sorpresa, e come non riceverle. Tre cause classiche: (1) un endpoint o Edge Function senza rate-limit che qualcuno scopre e martella — uso = denaro; (2) una query senza LIMIT né indice che scansiona tutta la tabella a ogni caricamento; (3) un loop di agente IA senza tetto di token né di iterazioni (l'invariante di capitale di XCAP esiste in parte per questo: loop che accumulano apprendimento, non costo). La difesa non è sorvegliare la fattura a fine mese, è mettere i freni PRIMA: budget con alert al 50/80/100%, limiti duri dove la piattaforma li offre, e tetti espliciti di max_tokens e di iterazioni in ogni chiamata di IA.
Alertare su ciò che conta, senza fatica da alert. Un alert che scatta ogni ora viene ignorato nel giro di una settimana. Il criterio: alerta solo ciò che è azionabile e urgente. Buono: 'la spesa del mese ha superato l'80% del budget', 'il tasso di 5xx è passato dell'1%', 'errori di client > X in 10 min'. Cattivo: 'c'è stata una richiesta'. Configura i budget alert che già ti danno Supabase, Cloudflare e la console di Anthropic — sono gratuiti e sono la tua prima linea. Ogni alert deve rispondere a 'cosa faccio quando scatta'; se non c'è un'azione, non è un alert, è ansia.
Chiudere il loop con la risposta agli incidenti (OS-04). Osservare senza un piano di reazione è solo guardare bruciare. Il pannello e gli alert di questo modulo sono i sensori; il rollback e l'incident response di OS-04 sono gli attuatori. Un sistema ben operato collega i due: l'alert ti sveglia, i log ti dicono cosa è successo, e il piano di risposta ti dice cosa fare. Osservabilità + costo + risposta = operare davvero, non pregare.
ESERCIZIO
Operi MOONKEY LAB (Astro SSG su Cloudflare Pages + Supabase moonkey-lab + possibili chiamate di IA). Monta la sua osservabilità reale e il suo budget in quattro passi. PASSO 1 — Mappa di segnali (deliverable scritto). Apri il Dashboard di Supabase → Logs e rivedi i tre stream (API, Postgres, Auth). Documenta in una tabella quale segnale sorvegli in ognuno (es. Auth: tasso di magic-link falliti; API: rapporto di 401/403; Postgres: query > 500ms), e di ognuno: soglia, cosa significa superarla, e quale azione innesca. Minimo 6 segnali. Uno senza azione si scarta. PASSO 2 — Cattura di errori di client. Implementa uno script globale (in Layout.astro, accanto all'IntersectionObserver già globale — non duplicarlo) con window.addEventListener('error') e ('unhandledrejection') che faccia fetch a una Edge Function di Supabase. Crea la Edge Function e una tabella client_errors con RLS di SOLO insert per anon (mai SELECT per anon: i log non si leggono dal client). Applicala come migrazione versionata ed esegui get_advisors dopo (regola di costo del progetto). Provoca un errore apposta e verifica che la riga compaia nella tabella. PASSO 3 — Le tre fatture e i loro tetti. Documenta i limiti attuali e dove si vedono: (a) Supabase — uso di DB, egresso, Auth MAU, invocazioni di funzioni (Dashboard → Reports/Usage); (b) Cloudflare — build e request (Pages → Analytics); (c) token di IA — se ci sono chiamate all'API di Claude, calcola il costo di una chiamata tipica (token di input del CLAUDE.md + prompt, token di output attesi, per la tariffa vigente del modello). Per ogni fattura, annota il tetto gratuito e a quale % sei oggi. PASSO 4 — Freni e alert. Attiva i budget alert nella console di Anthropic e in Supabase (o documenta esattamente dove si configurano se il tuo piano non li espone). Per qualsiasi chiamata di IA nel sistema, fissa un max_tokens esplicito e un tetto di iterazioni se è un loop. Consegna un runbook di una pagina: 'quando scatta l'alert X → guarda il log Y → esegui l'azione Z (che si collega con il rollback di OS-04)'. Non consegnare screenshot di 'sembra funzionare'. Consegna: la tabella di segnali, la Edge Function + migrazione funzionante con una riga di errore reale catturata, lo scomposto delle tre fatture con percentuali, e il runbook che collega alert → log → azione.
CONSEGNA
Un pannello di osservabilità operativo per MOONKEY LAB con: (1) una tabella di 6+ segnali (soglia · significato · azione) sui log reali di Supabase e Cloudflare; (2) cattura di errori di client funzionante — Edge Function + tabella client_errors con RLS solo-insert, applicata come migrazione versionata e verificata con una riga di errore reale; (3) lo scomposto delle tre fatture (Supabase, Cloudflare, token di IA) con il tetto gratuito e il % di uso attuale di ognuna; e (4) un runbook di una pagina con budget alert attivi che collega ogni alert con il suo log e la sua azione di risposta (collegandosi con il rollback di OS-04).
INTUIZIONE CHIAVE
In una SSG non c'è un server dove loggare, quindi la metà di ciò che fallisce avviene nel browser dell'utente e non ti arriva mai: se non catturi gli errori di client, non stai monitorando, stai indovinando. E il costo è una superficie d'attacco, non solo una voce contabile — un endpoint senza freno che qualcuno martella è allo stesso tempo un buco di sicurezza e una fattura a sorpresa, quindi i tetti (max_tokens, rate-limit, budget alert all'80%) sono sicurezza, non contabilità. La fattura non si sorveglia a fine mese; si mette il freno prima.
ERRORI DA EVITARE
- ×Credere che loggare = osservare. Accumulare log che nessuno legge non è osservabilità; osservabilità è avere una manciata di segnali che innescano decisioni. Una metrica che non aziona nulla è rumore: cancellala.
- ×Ignorare gli errori di client perché 'il server non riporta nulla'. In una SSG il server non PUÒ riportare il TypeError che rompe lo schermo dell'utente. Senza cattura nel browser, operi alla cieca su metà app.
- ×Aprire una tabella di log a SELECT per anon. I log si inseriscono dal client ma NON si leggono MAI da esso (filtreresti dati di altri utenti). RLS di solo-insert per anon, lettura solo admin — e passa get_advisors.
- ×Lanciare un loop di agente IA o una Edge Function senza max_tokens né tetto di iterazioni. È la causa nº1 di fattura a sorpresa: il costo scala invisibile mentre 'ciò che fa' la app non cambia.
- ×Sorvegliare la fattura a fine mese invece di mettere budget alert al 50/80/100% prima. Quando vedi l'addebito, l'hai già pagato; l'alert esiste per frenare, non per rimpiangere.
- ×Fatica da alert: configurare avvisi per tutto finché il team li silenzia. Alerta solo ciò che è azionabile e urgente; ogni alert deve rispondere a 'cosa faccio quando scatta' o non dovrebbe esistere.
- ×Toccare tabelle di altri progetti nel DB condiviso (iron_*, world_*, focus_*) mentre monti il logging. La tabella di errori è di MOONKEY; creala con prefisso proprio e non calpestare XHUB IRON.
Costellazione successiva
Operator Core
Operare l'IA