La galassia
ORBITA I · COSTELLAZIONE

Data & Systems

Backend che regge

Postgres, Supabase e RLS: dove vive il dato e chi può toccarlo. La sicurezza come autorità del server, non del client. La spina dorsale di XHUB, Espejo e questa scuola.

Alimenta XHUB IRONEspejoMOONKEY LAB
6 moduli · 6 lezioni
MODULI DELLA COSTELLAZIONE Apri ogni modulo per la lezione completa
DS-01

Modellazione in Postgres

lezione

Modellare uno schema in Postgres con tabelle, chiavi, tipi e relazioni che riflettano gli invarianti reali del dominio e che non ti costringano a migrazioni dolorose tre settimane dopo.

Il modello dei dati è la decisione più costosa da invertire di tutto lo stack: il codice lo riscrivi in un pomeriggio, ma una tabella con i tipi sbagliati e 50.000 righe in produzione ti perseguita per mesi. Nel DB condiviso di MOONKEY LAB (profiles, progress, proofs) un tipo debole o una FK dimenticata è un buco di integrità che nessuna RLS può sistemare.

LA LEZIONE

Postgres non è "un foglio di calcolo con SQL". È un motore relazionale con un sistema di tipi serio, e il tuo primo compito come operatore di dati è usare quel sistema di tipi affinché gli stati impossibili siano letteralmente irrappresentabili. Prima di scrivere un solo CREATE TABLE, elenca le entità del dominio e le relazioni tra di esse. In MOONKEY LAB le entità reali sono: un utente (profiles), il suo avanzamento per modulo (progress), le prove che carica (proofs), il feedback che lascia (feedback) e i lead di acquisizione. Ognuna è una tabella. Ogni relazione 1-a-N tra di esse è una foreign key. Non partire dalle colonne; parti dalle frecce tra i riquadri.

L'identificatore. In Supabase la tabella profiles dipende da auth.users, quindi la sua chiave primaria NON è un id autogenerato nuovo: è `id uuid primary key references auth.users(id) on delete cascade`. Questo è il pattern canonico di Supabase ed è deliberato — l'uuid dell'utente autenticato È l'identità in tutta la tua app, e `auth.uid()` (che userai in ogni policy RLS) restituisce esattamente quell'uuid. Per le tabelle figlie come progress o proofs invece usi una chiave propria: `id uuid primary key default gen_random_uuid()`, più una colonna `user_id uuid not null references auth.users(id) on delete cascade`. Fai attenzione a `not null` e a `on delete cascade`: se cancelli un utente, le sue prove se ne vanno con lui; se permettessi user_id nullo, avresti righe orfane che nessuna policy `user_id = auth.uid()` potrebbe né mostrare né proteggere.

I tipi contano e costa poco azzeccarli all'inizio. Per i timestamp usa SEMPRE `timestamptz` (with time zone), mai `timestamp` da solo — un progetto con utenti a Chiang Mai, Maiorca e Madrid non può permettersi ambiguità di fuso orario, e `timestamptz` salva in UTC e converte al confine. Per il denaro, `numeric`, mai `float` (i floating point binari non rappresentano 0.10 esatto). Per un campo con un insieme chiuso di valori —il rango dell'operatore: monkey, gorilla, eagle— hai due opzioni oneste: un `enum` di Postgres, oppure `text` con un `check (role in ('monkey','gorilla','eagle'))`. Preferisco il CHECK rispetto all'enum nei progetti giovani: aggiungere un valore a un enum richiede `ALTER TYPE` e ha attrito transazionale, mentre cambiare un CHECK è un ALTER di tabella ordinario. Per i dati semi-strutturati —il payload di una proof, metadati flessibili— usa `jsonb`, non `json`: jsonb è binario, indicizzabile con GIN e deduplica le chiavi.

Default e NOT NULL sono la tua prima linea di validazione, prima di qualsiasi RLS o qualsiasi check dell'app. `created_at timestamptz not null default now()` significa che è impossibile inserire una riga senza data. `status text not null default 'forming'` significa che lo stato non è mai nullo e quindi il tuo codice non ha mai bisogno di rami `if status is null`. Ogni colonna che lasci nullable è un ramo condizionale che paghi per sempre nel codice che la legge. La regola: una colonna è NOT NULL di default, e la rendi nullable solo quando l'assenza di valore è uno stato di dominio legittimo e distinto da un valore vuoto.

Unicità e indici. La relazione "un utente ha esattamente un record di progresso per modulo" non è un commento: è `unique (user_id, module_code)`. Quel vincolo unique ti dà inoltre l'indice di cui hai bisogno per l'upsert (`insert ... on conflict (user_id, module_code) do update`), che è esattamente come MOONKEY persiste l'avanzamento. A parte le chiavi, crea indici sulle colonne in base alle quali filtri davvero: se interroghi proofs per `user_id` di continuo, `create index on proofs (user_id)`. Ma non indicizzare per riflesso — ogni indice rallenta gli INSERT e occupa spazio; indicizza ciò che misuri essere interrogato, non ciò che immagini verrà interrogato.

Applica lo schema come una migrazione versionata, mai a mano nell'editor SQL. In Supabase questo è `apply_migration` (un file .sql con nome e timestamp), non `execute_sql` sciolto. La differenza è l'audit: tra sei mesi vorrai sapere perché proofs ha quella colonna, e la risposta è nello storico delle migrazioni, non nella tua memoria. Dopo aver applicato, esegui `generate_typescript_types` affinché il tipo TypeScript del client derivi dallo schema reale — così il DB è la fonte unica di verità e il front-end non può mentire sulla forma del dato.

Un esempio concreto e completo per proofs, la tabella dove un operatore carica la prova di aver completato un deliverable: una chiave propria uuid, un user_id NOT NULL con FK e cascade, un module_code con CHECK contro la lista di codici validi, un payload jsonb, un created_at timestamptz NOT NULL default now(), e un indice su user_id. Quel design fa sì che (a) ogni riga appartenga a un utente esistente, (b) nessuna riga resti orfana, (c) nessun module_code inventato entri, e (d) la query "le mie prove" sia veloce. Su quella base —e solo su quella— ha senso mettere la RLS nel modulo successivo.

ESERCIZIO

Nel progetto Supabase moonkey-lab (o in una branch di sviluppo), scrivi la migrazione SQL che crea la tabella `proofs` da zero: id uuid PK con gen_random_uuid(), user_id uuid NOT NULL references auth.users(id) on delete cascade, module_code text NOT NULL con CHECK contro almeno tre codici reali ('DS-01','DS-02','DS-03'), payload jsonb NOT NULL default '{}', created_at timestamptz NOT NULL default now(), e un indice su user_id. Applicala con apply_migration. Poi prova di proposito un INSERT con module_code='XX-99' e un altro con user_id di un uuid che non esiste in auth.users, e verifica che Postgres respinga entrambi. Infine esegui generate_typescript_types e verifica che il tipo Proof generato rifletta esattamente le tue colonne e nullabilità.

CONSEGNA

Un file di migrazione `supabase/migrations/<timestamp>_create_proofs.sql` applicato in una branch, più uno screenshot dell'errore di Postgres davanti all'INSERT non valido (violazione di CHECK e di FK) e il tipo TypeScript `Proof` generato a partire dallo schema.

INTUIZIONE CHIAVE

Il modello dei dati corretto è quello che converte un bug dell'applicazione in un errore della base di dati. Ogni CHECK, ogni NOT NULL e ogni FK è un'intera classe di bug che il tuo codice applicativo non deve più difendere — perché Postgres li respinge prima che esistano.

ERRORI DA EVITARE

  • ×Usare `timestamp` invece di `timestamptz` e scoprire lo sfasamento orario solo quando un utente in Thailandia vede date sbagliate; in produzione ormai è tardi.
  • ×Lasciare colonne nullable "per ogni evenienza": ogni nullable inutile è un ramo `if x is null` che ti trascini in tutto il codice che legge la tabella.
  • ×Creare la tabella profiles con un id proprio invece di `references auth.users(id)`, rompendo la catena tra auth.uid() e l'identità — e lasciando la RLS successiva senza un campo contro cui confrontare.
  • ×Applicare lo schema con execute_sql a mano invece di apply_migration: perdi lo storico versionato e nessuno potrà ricostruire perché lo schema è così com'è.
  • ×Seminare indici per riflesso su colonne in base alle quali non filtri mai: penalizzi ogni INSERT e occupi spazio senza accelerare nessuna query reale.
DS-02

RLS: l'autorità è il server

lezione

Scrivere e verificare policy Row Level Security che facciano sì che un utente possa leggere e scrivere solo le proprie righe, capendo che il gate del client è UX e la RLS è l'unica sicurezza reale in un sito statico.

MOONKEY LAB ed Espejo sono siti statici senza handler server-side: non c'è un backend dove infilare un `if (user.id === row.user_id)`. Il client porta con sé la anon key, che è pubblica per design. Se la RLS è sbagliata, chiunque con i DevTools aperti legge l'intera tabella profiles. La RLS non è uno strato di difesa in più: è l'UNICO strato.

LA LEZIONE

Interiorizza il modello di minaccia prima di toccare una policy. In una SSG con Supabase, l'attaccante NON usa la tua interfaccia. Apre la console del browser, prende la anon key (che è nel bundle, perché deve esserci), istanzia il proprio client Supabase e chiama `supabase.from('profiles').select('*')`. Il tuo file admin.astro che "nasconde" il pannello non esiste per lui. Il pulsante disabilitato non esiste per lui. L'unica cosa che si frappone tra il suo SELECT e tutte le righe di tutti gli utenti è la Row Level Security di Postgres. Per questo la frase del modulo: il gate del client è UX (migliora l'esperienza dell'utente legittimo), la RLS è la sicurezza (ferma l'illegittimo).

La RLS si attiva per tabella e di default è deny-all. `alter table profiles enable row level security` — e in quell'istante, senza nessuna policy, NESSUNO (tranne il service role e il proprietario della tabella) può leggere né scrivere nulla. Questo è corretto: parti da zero permessi e apri esattamente ciò che ti serve. L'errore catastrofico è abilitare la RLS e dimenticare un'operazione: se metti policy di SELECT ma nessuna di INSERT, gli INSERT falliscono silenziosamente; peggio, se NON abiliti la RLS su una tabella nuova, è completamente aperta alla anon key. Per questo DS-06 introduce gli advisor: rilevano esattamente la tabella senza RLS che ti sei dimenticato.

Una policy ha due clausole che confondono tutti: USING e WITH CHECK. USING filtra quali righe esistenti vede o tocca l'operazione (SELECT, UPDATE, DELETE). WITH CHECK valida quali righe nuove o modificate è permesso scrivere (INSERT, UPDATE). Per SELECT c'è solo USING. Per INSERT c'è solo WITH CHECK. Per UPDATE ci sono ENTRAMBE: USING dice quali righe puoi aggiornare, WITH CHECK dice in cosa possono trasformarsi. Un errore classico è mettere USING in un INSERT e che non faccia nulla, oppure mettere solo USING in un UPDATE e permettere che l'utente sposti la sua riga verso un altro user_id. Il pattern self-or-admin di MOONKEY: `using (user_id = auth.uid() or is_admin())`.

Il pattern canonico self. Per proofs, la policy di inserimento reale di MOONKEY è `proofs_insert_self`: `create policy proofs_insert_self on proofs for insert to authenticated with check (user_id = auth.uid())`. Leggila ad alta voce: un utente autenticato può inserire una proof solo se lo user_id della riga che inserisce è uguale alla sua stessa identità. Non può creare una proof a nome di un altro, perché WITH CHECK respingerebbe qualsiasi riga con user_id ≠ auth.uid(). Nota il `to authenticated`: la policy non si applica nemmeno al ruolo anon, quindi un visitatore senza sessione non può inserire nulla. Per SELECT, il pattern self-or-admin: `using (id = auth.uid() or is_admin())` su profiles — vedi la tua riga, o tutte se sei admin.

auth.uid() è il cuore del sistema e conviene sapere cos'è: una funzione SECURITY DEFINER di Supabase che estrae il `sub` dal JWT che il client invia a ogni richiesta. Il client non può falsificarlo perché il JWT è firmato da Supabase con un segreto che il client non ha; manipolarlo invalida la firma e Postgres respinge la sessione. Per questo `user_id = auth.uid()` è affidabile in un modo che `user_id = <valore che ha mandato il client>` non lo sarebbe mai. L'autorità vive nella firma del token, non nella buona fede del front-end.

Verifica per impersonazione — questo separa un operatore serio da uno che "crede" che la sua RLS funzioni. Non basta leggere la policy e annuire. Nell'editor SQL di Supabase puoi simulare di essere un utente concreto fissando il ruolo e il claim: `set local role authenticated; set local request.jwt.claims to '{"sub":"<uuid-dell-utente-A>"}';` e poi `select * from profiles`. Se vedi righe di altri utenti, la tua RLS è rotta. Il modello di sicurezza di MOONKEY dice esplicitamente "verificato per impersonazione": un non-admin non può leggere righe altrui, e questo è stato PROVATO, non assunto. Fai lo stesso: impersona l'utente A, prova a leggere la riga dell'utente B, ed esigi che il risultato sia zero righe.

Due trappole finali. Prima: il ruolo `service_role` (la service key, MAI nel client) bypassa tutta la RLS — per questo non vive mai in una SSG e si usa solo in edge function fidate. Seconda: le policy sono permissive di default e si combinano con OR, non con AND. Se hai due policy di SELECT sulla stessa tabella, una riga visibile a UNA QUALSIASI delle due è visibile. Questo sorprende: aggiungere una policy non restringe mai, amplia solo. Per restringere ti servono policy RESTRICTIVE esplicite, oppure —più semplice e l'approccio abituale— una sola policy ben scritta per operazione.

ESERCIZIO

In una branch di moonkey-lab, sulla tabella proofs già creata: abilita la RLS, e scrivi tre policy — `proofs_select_self` (SELECT, using user_id=auth.uid() or is_admin()), `proofs_insert_self` (INSERT, with check user_id=auth.uid()), e NESSUNA policy di UPDATE/DELETE (deny di default). Crea due utenti di prova A e B con rispettive proofs. Nell'editor SQL, impersona A (set local role authenticated + jwt claims sub=uuid_A) e verifica: (1) un SELECT restituisce solo la proof di A; (2) un INSERT con user_id=uuid_B è respinto da WITH CHECK; (3) un DELETE della proof di A è respinto perché non c'è policy. Documenta ognuno dei tre risultati.

CONSEGNA

Migrazione con le tre policy su proofs, più un log della sessione di impersonazione che dimostra i tre comportamenti: A non vede B (SELECT), A non può scrivere come B (INSERT con WITH CHECK), e nessuno può cancellare (senza policy DELETE).

INTUIZIONE CHIAVE

Una RLS che non hai rotto di proposito impersonando un altro utente è una RLS di cui non sai se funziona. La fiducia nella sicurezza non viene dal leggere la policy; viene dall'aver tentato l'abuso e aver visto Postgres negarlo.

ERRORI DA EVITARE

  • ×Confondere il gate di admin.astro con la sicurezza: nascondere il pannello nel front-end non impedisce all'attaccante di chiamare la tabella direttamente con la anon key dalla console.
  • ×Mettere USING dove va WITH CHECK (o viceversa): un INSERT con solo USING non valida nulla e lascia entrare righe con user_id altrui.
  • ×Abilitare la RLS e dimenticare la policy di qualche operazione: gli INSERT iniziano a fallire in silenzio, o peggio, una tabella senza RLS resta spalancata alla anon key.
  • ×Assumere che la RLS funzioni per averla letta, invece di verificarla per impersonazione con set role + jwt claims ed esigere zero righe altrui.
  • ×Credere che aggiungere una seconda policy permissiva restringa l'accesso: le policy si combinano con OR, quindi ogni policy nuova amplia solo ciò che è visibile, non lo riduce mai.
DS-03

Auth magic-link e sessioni

lezione

Implementare l'autenticazione tramite magic-link con Supabase e gestire la sessione lato client in modo onesto: sapere cosa garantisce la sessione, cosa no, e perché questo non indebolisce la tua RLS.

MOONKEY LAB ed Espejo usano login senza password: l'utente inserisce la sua email, riceve un link, ci clicca e resta autenticato. È la migliore UX ed elimina un'intera classe di vulnerabilità (non immagazzini password, non ci sono fughe di hash). Ma la sessione vive nel browser, e un operatore che non capisce dove e come si salva il token confonderà comodità con sicurezza.

LA LEZIONE

Come funziona il magic-link, passo dopo passo. Il client chiama `supabase.auth.signInWithOtp({ email })`. Supabase genera un token monouso, lo associa all'email e manda una mail con un link che punta alla tua app con quel token nel fragment dell'URL. L'utente ci clicca; la tua app, al caricamento, rileva il token, lo scambia con Supabase per una coppia access_token / refresh_token, e da lì in poi il client è autenticato. L'access_token è un JWT firmato con scadenza breve (di default un'ora); il refresh_token è di lunga durata e serve a ottenere nuovi access token senza che l'utente rifaccia il login. Tutto questo lo orchestra l'SDK; il tuo compito è capire il flusso, non reimplementarlo.

Dove vive la sessione. Di default, l'SDK di Supabase nel browser persiste la sessione in localStorage. Questo ha una conseguenza di sicurezza che devi dire ad alta voce: un token in localStorage è leggibile da qualsiasi JavaScript che giri sulla tua pagina. Questo significa che la tua superficie d'attacco numero uno è l'XSS — se un attaccante riesce a iniettare JS nel tuo sito (uno script di terzi compromesso, un innerHTML con input non sanificato), può leggere il token e rubare la sessione. La difesa non è nascondere il token; è non avere XSS: non mettere HTML dell'utente non sanificato, fai audit di ogni dipendenza front-end, e tratta ogni `<script>` di terzi come codice che vedrà i tuoi token.

La distinzione onesta che dà il nome al modulo: la sessione è identità, non autorizzazione. Che il client abbia un access_token valido prova CHI è (auth.uid() restituirà il suo uuid), ma NON gli dà di per sé il permesso per nulla. Il permesso lo decide la RLS a ogni query. Questo è liberatorio: non devi difendere i tuoi dati nel front-end. Anche se un attaccante ruba una sessione, può fare solo ciò che la RLS permette a QUELL'utente — vedere le proprie righe, non quelle altrui. La sessione rubata di un utente normale non dà accesso admin, perché is_admin() rivalida contro la riga di profiles, non contro un claim che il client possa manipolare.

Gestione dello stato di sessione nell'app. L'SDK espone `supabase.auth.getSession()` (legge la sessione attuale, eventualmente dal localStorage) e `supabase.auth.onAuthStateChange((event, session) => ...)` (ti notifica di SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED). In una SSG come MOONKEY non hai server-side rendering dello stato di auth, quindi la pagina carica prima in stato "sconosciuto" e poi, sul client, risolvi se c'è sessione. Progetta per questo: mostra uno stato neutro di caricamento, non far lampeggiare tra "ospite" e "loggato". Il gate visivo (mostrare /cuenta solo se c'è sessione) è UX legittima — ricorda DS-02: è UX, la sicurezza resta nella RLS.

Il trigger che chiude il cerchio. Quando un utente si registra per la prima volta via magic-link, Supabase crea una riga in auth.users. Ma la tua app ha bisogno di una riga corrispondente in profiles. Questo NON lo fa il client (non deve poter scegliere il proprio role né founder_badge). Lo fa un trigger SECURITY DEFINER nella base di dati: `handle_new_user`, che si attiva `after insert on auth.users` e crea la riga di profiles con valori di default sicuri (role='monkey', mai admin). Così la creazione del profilo è autoritativa dal server: l'utente non può nascere admin perché il trigger, non il client, decide i valori iniziali. Questo pattern lo sviluppi a fondo in DS-04.

Logout e scadenza, fatti onestamente. `supabase.auth.signOut()` cancella i token dal localStorage e revoca il refresh_token sul server. Importante: se cancelli solo il localStorage a mano senza chiamare signOut, il refresh_token resta valido sul server — fallo sempre tramite l'SDK. Sulla scadenza: non promettere "sessione per sempre". L'access_token scade in un'ora e l'SDK lo rinfresca con il refresh_token in modo trasparente; se il refresh_token viene revocato o scade, l'utente torna al login. Comunica questo all'utente con onestà invece di fingere una persistenza eterna.

Configura gli URL di redirect nel pannello di Supabase (Auth > URL Configuration). Il magic-link reindirizza a un URL che DEVE essere nell'allowlist, altrimenti Supabase rifiuta lo scambio — questo impedisce che un attaccante faccia reindirizzare il link a un dominio che controlla. In MOONKEY, i redirect URL includono il dominio di produzione (moonkeylab.pages.dev) e localhost per lo sviluppo, e nient'altro. Un'allowlist di redirect lasca è un vero vettore di phishing.

ESERCIZIO

In MOONKEY (o un clone locale che punta a una branch di Supabase) implementa il flusso completo: una pagina di login che chiama signInWithOtp con l'email dell'utente e mostra "controlla la tua posta"; la gestione dello scambio al ritorno dal link; e una pagina /cuenta che usa getSession + onAuthStateChange per mostrare l'email dell'utente loggato o reindirizzare al login se non c'è sessione. Verifica tre cose: (1) dopo il clic sul magic-link esiste una riga in profiles creata dal trigger handle_new_user con role='monkey'; (2) signOut cancella la sessione e revoca il refresh token; (3) configura i redirect URL nel pannello e conferma che un redirect verso un dominio NON elencato viene rifiutato.

CONSEGNA

Un flusso di login tramite magic-link funzionante contro una branch di Supabase, con screenshot di: la riga profiles autocreata dal trigger (role='monkey'), lo stato di sessione letto in /cuenta, e la schermata di Auth > URL Configuration che mostra l'allowlist di redirect URL.

INTUIZIONE CHIAVE

La sessione prova chi sei, non cosa puoi fare. Se la tua sicurezza si rompe quando qualcuno ruba una sessione, vuol dire che ti affidavi al client per autorizzare — e l'autorizzazione deve sempre vivere nella RLS, dove una sessione rubata apre solo ciò che QUELL'utente poteva già vedere.

ERRORI DA EVITARE

  • ×Trattare il token in localStorage come un segreto sicuro: è leggibile da qualsiasi JS della pagina, quindi la tua vera difesa è non avere XSS, non nascondere il token.
  • ×Confondere l'avere una sessione con l'avere un permesso: la sessione dà identità (auth.uid()), ma ogni accesso lo decide comunque la RLS — non autorizzare mai nel front-end.
  • ×Creare la riga di profiles dal client invece che con il trigger handle_new_user: lasceresti che l'utente scegliesse il proprio role e apriresti l'auto-escalation ad admin.
  • ×Cancellare il localStorage a mano invece di chiamare signOut: il refresh_token resta vivo sul server e la sessione può riprendere.
  • ×Lasciare l'allowlist di redirect URL aperta o con wildcard: trasforma il magic-link in un vettore di phishing che reindirizza a un dominio dell'attaccante.
DS-04

SECURITY DEFINER, RPC e trigger

lezione

Scrivere funzioni SECURITY DEFINER, RPC e trigger che eseguano logica privilegiata in modo controllato, senza aprire buchi di escalation dei privilegi.

Ci sono operazioni che la RLS da sola non può esprimere: controllare se qualcuno è admin (il controllo stesso ha bisogno di leggere profiles, il che creerebbe ricorsione), promuovere il rango di un operatore rivalidando le regole, o impedire che un utente si assegni da solo il founder_badge. SECURITY DEFINER è lo strumento — ed è esattamente dove, usato male, apri la porta sul retro che tutta la tua RLS cercava di chiudere.

LA LEZIONE

Cosa significa SECURITY DEFINER. Una funzione normale in Postgres gira con i permessi di chi la CHIAMA (SECURITY INVOKER, il default). Una funzione SECURITY DEFINER gira con i permessi di chi l'ha CREATA (tipicamente un ruolo con privilegi, proprietario delle tabelle). Questo le permette di fare cose che il chiamante non potrebbe fare direttamente — per esempio, leggere profiles per controllare un ruolo, anche se la RLS negherebbe all'utente quel SELECT. È potente ed è pericoloso: una SECURITY DEFINER è un piccolo pezzo di codice che gira al di sopra della RLS. Ognuna è un'eccezione al tuo modello di sicurezza, quindi ognuna va auditata come tale.

Il caso is_admin(). Devi sapere se l'utente attuale è admin per usarlo nelle policy (`using (... or is_admin())`). Ma se la policy di SELECT di profiles dipende dal leggere profiles per sapere il ruolo, hai ricorsione infinita: per leggere la tua riga devi sapere se sei admin, per saperlo leggi profiles, il che attiva di nuovo la policy. La soluzione è is_admin() come SECURITY DEFINER: gira con i privilegi del proprietario, legge profiles SENZA passare per la RLS, restituisce un booleano. Cosa cruciale, NON restituisce dati sensibili — solo true/false sul chiamante (`select role = 'admin' from profiles where id = auth.uid()`). Una SECURITY DEFINER sicura espone l'informazione minima: una decisione, non un dataset.

Blindare il search_path — questa è la vulnerabilità classica e quella che gli advisor segnalano senza pietà. Una SECURITY DEFINER che non fissa il suo search_path è sfruttabile: un attaccante crea una tabella o funzione con lo stesso nome di una che la tua funzione usa, in uno schema che sta prima nel search_path, e la tua funzione privilegiata esegue il codice dell'attaccante con i permessi del proprietario. La difesa è obbligatoria: `create function is_admin() ... security definer set search_path = '' as $$ ... $$;` (o `set search_path = pg_catalog, public` qualificando esplicitamente). Con search_path vuoto, riferisci ogni tabella con il suo schema completo: `public.profiles`, non `profiles`. Senza questo, la tua funzione di sicurezza È il buco.

RPC: logica di business invocabile dal client. Un RPC in Supabase è una funzione Postgres esposta via `supabase.rpc('nome', args)`. In MOONKEY gli RPC reali sono update_operator_rank (promuove il rango rivalidando), my_referral_stats (restituisce le statistiche di referral dell'utente) e is_admin. Il pattern d'oro: l'RPC NON si fida degli argomenti del client per l'identità. update_operator_rank non riceve "quale utente promuovere" come parametro libero — usa auth.uid() internamente. Se ricevesse uno user_id come argomento, un attaccante chiamerebbe rpc('update_operator_rank', { user_id: 'quello di un altro' }). L'identità esce SEMPRE da auth.uid() dentro la funzione, mai da un parametro che il client controlla.

L'RPC rivalida, non obbedisce. update_operator_rank non è "metti il mio rango a X perché lo chiedo". Rivalida le regole: l'utente ha completato le prove richieste per quel rango? Il commento del modello di sicurezza di MOONKEY è esplicito — "Ranghi solo via update_operator_rank (rivalida il ruolo)". Il client non può saltare da monkey a admin chiedendolo; l'RPC controlla le condizioni reali nel DB e solo allora scrive. Questa è la differenza tra un RPC che è un'API di business (verifica gli invarianti) e uno che è un buco (scrive ciò che gli dicono). Concedi EXECUTE solo a `authenticated`, mai a anon: `grant execute on function update_operator_rank to authenticated`.

Trigger: invarianti che si applicano a prescindere. Alcuni invarianti non possono dipendere dal fatto che l'app li rispetti. "Un non-admin non può mai cambiare il proprio role né il proprio founder_badge" è uno di questi: se dipendesse dall'app, qualsiasi UPDATE diretto via anon key lo aggirerebbe. La soluzione di MOONKEY è il trigger guard_privileged_profile_columns, che si attiva `before update on profiles` e, se il chiamante non è admin e sta provando a cambiare role o founder_badge, lancia un'eccezione che aborta la transazione. Combinato con handle_new_user (after insert on auth.users, crea profiles con role='monkey'), il risultato è che un utente NASCE come monkey e NON PUÒ auto-promuoversi — né tramite l'app, né tramite un UPDATE grezzo con la anon key. Il trigger è la rete sotto la RLS.

La disciplina di audit. Per ogni SECURITY DEFINER che scrivi: ha il search_path fissato? Restituisce l'informazione minima possibile? Deriva l'identità da auth.uid() e non da un parametro? Il suo EXECUTE è ristretto al ruolo corretto? Dopo aver creato o cambiato una qualsiasi di queste funzioni, esegui get_advisors (DS-06): l'advisor di sicurezza segnala le SECURITY DEFINER senza search_path e le funzioni con permessi laschi. Una SECURITY DEFINER è codice privilegiato; trattala con la paranoia che merita un codice che gira al di sopra della tua stessa sicurezza.

ESERCIZIO

In una branch di moonkey-lab: (1) Scrivi is_admin() come SECURITY DEFINER con `set search_path = ''`, che legga public.profiles e restituisca un booleano su auth.uid(). (2) Scrivi un trigger before-update su profiles che aborti se un non-admin prova a modificare role o founder_badge, e implementalo derivando admin da is_admin(). (3) Prova l'abuso: con una sessione di utente normale (impersonato), prova `update profiles set role='admin' where id=auth.uid()` e verifica che il trigger lanci l'eccezione. (4) Esegui get_advisors(type='security') e conferma che non compaia nessun warning di search_path mutabile sulle tue funzioni.

CONSEGNA

Migrazione con is_admin() (SECURITY DEFINER, search_path fissato) e il trigger guard su profiles, più evidenza di: l'UPDATE di auto-escalation respinto dal trigger, e un get_advisors di sicurezza pulito (senza warning di search_path).

INTUIZIONE CHIAVE

SECURITY DEFINER è l'unica parte del tuo sistema che gira al di sopra della RLS, quindi è l'unico posto dove una distrazione scala a una breccia totale. La regola minima non negoziabile: search_path fissato, identità da auth.uid() mai da parametri, e permessi di EXECUTE ristretti — perché qui non c'è una seconda rete.

ERRORI DA EVITARE

  • ×Creare una SECURITY DEFINER senza `set search_path`: lascia che un attaccante dirotti i nomi di tabella/funzione ed esegua il proprio codice con i permessi del proprietario — gli advisor la segnalano per un motivo.
  • ×Passare lo user_id come argomento dell'RPC invece di usare auth.uid() all'interno: il client chiamerebbe l'RPC con l'id di un altro utente e opererebbe a suo nome.
  • ×Far sì che update_operator_rank obbedisca al rango richiesto invece di rivalidare le condizioni: trasforma la promozione in auto-escalation con un clic.
  • ×Affidarsi all'app per impedire che un utente cambi il proprio role: un UPDATE diretto con la anon key lo aggirerebbe; l'invariante deve vivere in un trigger.
  • ×Concedere l'EXECUTE degli RPC a anon o a public invece che solo a authenticated: esponi logica di business privilegiata a richieste senza sessione.
DS-05

Sync locale ↔ cloud

lezione

Progettare una sincronizzazione locale↔cloud dove lo stato che vive in localStorage sale a Postgres senza perdere dati né creare duplicati, risolvendo i conflitti in modo deterministico.

Espejo parte con il suo stato in localStorage (il suo seam Store è pensato per passare da localStorage a Supabase senza riscrivere l'app) e MOONKEY persiste il progresso dell'operatore che prima esiste nel browser e poi deve salire al cloud quando l'utente fa login. Se la sync è ingenua, un utente che ha avanzato offline e poi fa login perde il suo progresso, o lo duplica, o sovrascrive ciò che aveva su un altro dispositivo.

LA LEZIONE

Il problema reale non è "copiare dati". È riconciliare due fonti di verità che sono evolute separatamente: il localStorage di questo browser e la riga in Postgres (che può essere cambiata da un altro dispositivo). Prima di scrivere codice, decidi la policy di conflitto esplicitamente, perché "quello che succede" è la ricetta della perdita di dati. Le opzioni oneste: last-write-wins (vince il timestamp più recente, semplice ma può perdere edit concorrenti), merge per campo (combini campo per campo secondo regole), o append-only (non sovrascrivi mai, aggiungi soltanto, e derivi lo stato). Per il progresso di apprendimento —che è monotono, avanza soltanto— la migliore policy è di solito "vince il massimo": se locale dice modulo 3 completato e cloud dice modulo 5, il risultato è 5; non si torna mai indietro.

Il localStorage come strato, non come verità. Il pattern del seam Store di Espejo è l'astrazione corretta: la tua app non chiama localStorage né Supabase direttamente, chiama uno Store con un'interfaccia (get, set, list). C'è un'implementazione LocalStore (localStorage) e una SupabaseStore (Postgres). L'app non sa quale usa. Questo trasforma il "passare al cloud" da una riscrittura a un cambio di implementazione dietro la stessa interfaccia. La sync, allora, è un'operazione tra due Store: leggere tutto da LocalStore, riconciliare con SupabaseStore, scrivere il risultato in entrambi. Costruisci il seam PRIMA di aver bisogno del cloud; è economico all'inizio e costosissimo da retrofittare.

L'idempotenza è la proprietà che ti salva. La sync si interromperà: l'utente chiude la scheda a metà, la rete cade, l'SDK ritenta. Se la tua sync non è idempotente, una seconda esecuzione crea duplicati. Lo strumento è l'upsert con chiave naturale: `insert into progress (user_id, module_code, completed_at) values (...) on conflict (user_id, module_code) do update set completed_at = greatest(progress.completed_at, excluded.completed_at)`. Quella unique (user_id, module_code) di DS-01 è esattamente ciò che rende possibile l'upsert. Esegui la sync due volte di fila: se lo stato finale è identico, è idempotente. Se la seconda volta duplica righe o cambia qualcosa, hai un bug che in produzione si manifesta come dati corrotti a mezzanotte.

Il momento critico: il primo login dopo aver lavorato come ospite. L'utente ha avanzato in localStorage senza sessione, poi fa magic-link (DS-03) e ottiene un auth.uid(). Ora bisogna adottare lo stato anonimo sotto la sua identità. Il flusso sicuro: allo scattare di onAuthStateChange con SIGNED_IN, leggi il progresso da LocalStore, lo carichi con upsert legando user_id = auth.uid(), e solo allora marchi il locale come sincronizzato. NON cancellare il locale finché non hai confermato che il cloud l'ha ricevuto (la conferma è la risposta senza errori dell'upsert). Se cancelli prima e l'upload fallisce, hai perso il dato. Ordine: caricare, confermare, marcare sincronizzato, opzionalmente pulire.

La RLS comanda ancora durante la sync. Quando carichi il progresso con la sessione dell'utente, l'upsert va con il suo JWT, quindi la policy `with check (user_id = auth.uid())` si applica: non puoi caricare progresso a nome di un altro, anche se il localStorage dicesse altro. Questo è positivo — la sync non è una porta sul retro alla sicurezza. Significa che devi legare user_id ad auth.uid() nel momento di caricare, non usare uno user_id che ti trascinavi dallo stato anonimo (che non aveva un'identità reale). La sync rispetta il modello: il server resta l'autorità su a chi appartiene ogni riga.

Conflitti tra dispositivi, il caso che la gente dimentica. L'utente avanza sul cellulare (carica al cloud), poi apre il portatile che aveva uno stato locale vecchio. Senza attenzione, il portatile sovrascrive il cloud con dati arretrati. La difesa: la riconciliazione NON è "locale calpesta cloud", è "riconciliare entrambi secondo la policy". Per il progresso monotono, porti il cloud, fai il merge "vince il massimo" con il locale, e scrivi il risultato in entrambi. Così il portatile impara ciò che ha fatto il cellulare invece di cancellarlo. Per dati non monotoni ti servono timestamp per campo (updated_at) e last-write-wins per campo, il che richiede di salvare quei timestamp fin dall'inizio — un altro motivo per i timestamptz di DS-01.

Stati della sync, visibili e onesti. Modellali esplicitamente: synced (locale == cloud), pending (ci sono cambiamenti locali non caricati), syncing (in corso), error (fallito, ritentare). Non mentire all'utente con una spunta verde se l'upload è fallito. Un indicatore onesto di "cambiamenti non salvati" evita che l'utente chiuda la scheda credendo di essere al sicuro. La sync silenziosa che fallisce in silenzio è peggio del non avere sync: l'utente si fida e perde dati senza saperlo.

ESERCIZIO

Sulla tabella progress (user_id, module_code, completed_at, con unique(user_id, module_code)): implementa un seam Store con due backend, LocalStore (localStorage) e SupabaseStore. Scrivi una funzione sync() che: legga il progresso locale, lo riconcili con quello del cloud usando 'vince il completed_at massimo' via upsert con `on conflict do update set completed_at = greatest(...)`, legando user_id = auth.uid(). Prova tre scenari: (1) idempotenza — esegui sync() due volte e verifica stato finale identico, zero duplicati; (2) primo login — avanza come ospite, fai login, e conferma che il progresso anonimo compare sotto il tuo user_id in Postgres; (3) due dispositivi — simula cloud con modulo 5 e locale con modulo 3, esegui sync, e verifica che il risultato sia 5 su entrambi i lati (non torna indietro).

CONSEGNA

Un modulo Store con interfaccia comune e due implementazioni (LocalStore/SupabaseStore) più una funzione sync() idempotente basata su upsert, con un log dei tre scenari: doppia esecuzione senza duplicati, adozione dello stato anonimo al login, e riconciliazione vince-il-massimo tra due dispositivi senza perdita.

INTUIZIONE CHIAVE

La sync non è copiare dati, è riconciliare due fonti di verità che hanno divergito — e l'unico modo per non perdere nulla è scegliere la policy di conflitto esplicitamente e rendere l'operazione idempotente con un upsert su chiave naturale. Se non puoi eseguire la tua sync due volte di fila con lo stesso risultato, non hai una sync, hai una bomba a orologeria.

ERRORI DA EVITARE

  • ×Cancellare il localStorage prima di confermare che l'upload al cloud è riuscito: se l'upsert fallisce, il dato è perso per sempre.
  • ×Sync non idempotente senza upsert su chiave naturale: un'esecuzione interrotta e ritentata duplica righe che compaiono come corruzione a ore improbabili.
  • ×Lasciare che il dispositivo con stato vecchio calpesti il cloud ('vince il locale') invece di riconciliare: il portatile cancella ciò che il cellulare aveva avanzato.
  • ×Trascinare lo user_id dallo stato anonimo invece di legarlo ad auth.uid() al momento del caricamento: la policy WITH CHECK lo respingerà, o peggio, proverai a scrivere sotto un'identità che non è quella reale.
  • ×Mostrare una spunta verde di 'sincronizzato' quando l'upload è fallito: l'utente si fida, chiude la scheda e perde il lavoro senza saperlo.
DS-06

Advisor, migrazioni e audit

lezione

Operare la base di dati con cambiamenti versionati tramite migrazioni e usare gli advisor di Supabase come un linter di sicurezza continuo che ti avvisa di tabelle senza RLS, funzioni senza search_path e altri buchi prima che arrivino in produzione.

MOONKEY condivide la base di dati con XHUB IRON: un cambiamento sbadato può calpestare tabelle di un altro progetto o lasciare una nuova tabella senza RLS aperta alla anon key. Senza migrazioni versionate non c'è modo di sapere cosa è cambiato né di tornare indietro; senza gli advisor, scopri il buco di sicurezza quando qualcuno l'ha già sfruttato. Questa è la disciplina che mantiene onesto tutto ciò che precede.

LA LEZIONE

Migrazioni: la base di dati come codice versionato. Ogni cambiamento di schema —una tabella, una colonna, una policy, una funzione— è un file .sql con timestamp in supabase/migrations/, applicato con apply_migration, mai con execute_sql a mano. La differenza è la stessa che c'è tra committare e modificare file in produzione via SSH: una ti dà storico, revisione e rollback; l'altra ti dà amnesia. Il nome del file (`<timestamp>_create_proofs.sql`, `<timestamp>_add_proofs_rls.sql`) racconta la storia dello schema. Quando tra sei mesi ti chiederai perché una colonna esiste, la risposta è nella migrazione che l'ha introdotta, con il suo nome e la sua data — non nella tua memoria né in quella di nessun altro.

execute_sql è per LEGGERE, apply_migration è per CAMBIARE. Questa regola operativa evita l'errore più comune. Usa execute_sql per ispezionare (select, explain, controllare lo stato), per impersonare e verificare la RLS (DS-02), per l'esplorazione. Nel momento in cui l'SQL altera lo schema o le policy in un modo che vuoi che persista, va in una migrazione. Un cambiamento di sicurezza applicato con execute_sql che funziona ma non è versionato è debito: nessuno sa che esiste, nessuno può rivederlo, e ricreando il progetto sparisce.

Branch di Supabase per non rompere la produzione. Prima di applicare una migrazione d'impatto, creala in una branch (create_branch), provala lì —inclusa la verifica per impersonazione di DS-02 e il get_advisors di DS-04— e solo allora fanne il merge in produzione (merge_branch). La branch ha la sua base di dati effimera; rompi ciò che vuoi senza toccare utenti reali. Questo è particolarmente critico in MOONKEY perché il DB è condiviso: una migrazione che tocca per errore una tabella iron_* o world_* di XHUB si prova e si scarta nella branch, non nella base viva che serve due progetti.

Gli advisor sono il tuo linter di sicurezza. get_advisors(type='security') esegue un insieme di controlli che rilevano esattamente i buchi che questi moduli insegnano a evitare: tabelle con RLS disabilitata (DS-02), funzioni SECURITY DEFINER con search_path mutabile (DS-04), policy che espongono dati, colonne senza protezione. get_advisors(type='performance') segnala l'altro versante: foreign key senza indice, indici duplicati, query non coperte. La disciplina non negoziabile del CLAUDE.md di MOONKEY: 'Cambiamenti di RLS/sicurezza: applicare come migrazione versionata + get_advisors dopo'. Ogni volta che tocchi la sicurezza, l'advisor è la chiusura del ciclo — non assumi che vada bene, lo verifichi con lo strumento.

Come leggere un advisor e agire. Un warning dell'advisor non è rumore da silenziare; è una vulnerabilità concreta con un rimedio concreto. 'RLS disabled on public.proofs' significa che chiunque con la anon key legge la tabella — il rimedio è enable row level security + policy, in una migrazione. 'Function public.is_admin has a role mutable search_path' significa che la funzione è dirottabile — il rimedio è `alter function ... set search_path = ''`, in una migrazione. La routine matura: tocchi qualcosa → migrazione → get_advisors → se c'è un warning, un'altra migrazione che lo chiude → get_advisors pulito. Non c'è 'lo sistemo dopo' negli advisor di sicurezza; dopo è dopo la breccia.

Isolamento nel DB condiviso, il rischio specifico di MOONKEY. La base ospita tabelle di MOONKEY (profiles, progress, feedback, leads, proofs) e di XHUB IRON (iron_*, world_*, focus_*, daily_focus_history). La tua disciplina di migrazioni deve rispettare quel confine: una migrazione di MOONKEY NON deve MAI alterare, nemmeno per distrazione di un DROP o di un ALTER troppo ampio, una tabella dell'altro progetto. Prima di applicare, leggi il diff della migrazione come un avversario: tocca solo le tabelle che ho detto? L'advisor e la revisione dell'SQL sono le due reti. In un DB condiviso, un cambiamento mal delimitato non è un bug tuo, è un incidente di un altro progetto.

Audit come abitudine, non come evento. L'audit non è una cosa che fai prima di un launch; è lo stato di default dell'operare dati sul serio. list_migrations ti dà lo storico completo di come lo schema è arrivato dov'è. get_logs ti mostra cosa sta fallendo in tempo reale. get_advisors è il check di salute che esegui dopo ogni cambiamento e periodicamente anche se non cambi nulla (perché Supabase aggiunge nuovi controlli e perché il contesto cambia). L'operatore che tratta la base di dati come un sistema vivo che si audita di continuo è quello che non riceve la chiamata delle 3 di notte — perché ha visto il warning nella branch, una settimana prima, con get_advisors.

ESERCIZIO

Prendi la migrazione di RLS di proofs che hai scritto in DS-02 ma stavolta con disciplina completa: (1) crea una branch di moonkey-lab; (2) applica in essa, come migrazioni versionate separate e con nomi descrittivi, la creazione della tabella e le sue policy; (3) esegui get_advisors(type='security') PRIMA delle policy e conferma che compaia il warning 'RLS disabled' su proofs; (4) applica le policy ed esegui di nuovo get_advisors, confermando che il warning scompare; (5) introduci di proposito una funzione SECURITY DEFINER senza search_path, verifica che l'advisor la segnali, sistemala con set search_path='' in un'altra migrazione, e conferma advisor pulito; (6) fai merge_branch in produzione solo con l'advisor in verde. Documenta la lista finale di migrazioni con list_migrations.

CONSEGNA

Una branch con migrazioni versionate e nominate per tabella+policy+funzione, più una sequenza di output di get_advisors che dimostra il ciclo di chiusura-warning: 'RLS disabled' presente → assente dopo le policy, 'mutable search_path' presente → assente dopo il fix, e un get_advisors finale pulito prima del merge in produzione.

INTUIZIONE CHIAVE

Gli advisor convertono il tuo modello di sicurezza da qualcosa che credi di aver fatto bene a qualcosa che lo strumento conferma essere a posto. La regola dell'operatore serio: nessun cambiamento di sicurezza si considera concluso finché get_advisors non è pulito — perché il costo di un warning ignorato non è un warning, è una breccia che scopri nel modo peggiore.

ERRORI DA EVITARE

  • ×Applicare cambiamenti di schema o policy con execute_sql invece di apply_migration: perdi storico, revisione e rollback, e il cambiamento sparisce ricreando il progetto.
  • ×Toccare la base di produzione condivisa direttamente invece di provare in una branch: un ALTER o DROP troppo ampio si trasforma in un incidente per XHUB IRON.
  • ×Saltare get_advisors dopo un cambiamento di sicurezza: lasci viva proprio la tabella senza RLS o la funzione senza search_path che l'advisor avrebbe segnalato in pochi secondi.
  • ×Trattare un warning dell'advisor come rumore da silenziare invece di una vulnerabilità con un rimedio concreto: 'lo sistemo dopo' nella sicurezza è 'lo sistemo dopo la breccia'.
  • ×Non leggere il diff della migrazione come un avversario prima di applicare in un DB condiviso: un cambiamento mal delimitato calpesta tabelle iron_*/world_* di un altro progetto senza che te ne accorga.

Costellazione successiva

Builders

Spedire prodotto