Ogni ricerca semantica di uno dei miei progetti costa soldi: un embedding, una chiamata di reranking e una sintesi di testo. Centesimi a richiesta. Moltiplicato per un bot con un ciclo, non sono centesimi. La prima versione non chiedeva un account per niente, perché chiedere un account per cercare anime sembrava assurdo. Continua a sembrarlo. Lo faccio lo stesso.
Il motivo non è il marketing né la mailing list. È che senza identità non c'è niente da contare.
Il costo di far entrare senza identificare
Ho messo su un harness di validazione fake-door con cinque landing in parallelo per misurare l'interesse prima di costruire qualcosa. L'unica cosa da proteggere era il pannello di admin, quindi ho messo il ban dell'IP dopo alcuni tentativi di login falliti. Sufficiente, pensavo.
Non lo era. L'IP del client arrivava da x-forwarded-for, e quell'header è una lista client, proxy1, proxy2… dove ogni proxy aggiunge a destra ma nessuno ripulisce quello che c'era già scritto. Prendere il primo valore significa prendere quello che il client vuole scriverci. Chiunque manda x-forwarded-for: 1.2.3.4, evita il proprio ban e per giunta può bannare l'IP di un altro scrivendo il suo.
L'ho sistemato contando i salti invece di fidarmi della sinistra:
const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map((s) => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];
Con un proxy davanti, il client vero è l'ultimo valore: quello che scrive il proxy e che il client non può toccare. Funziona. Ma guarda cosa ho appena fatto: ho messo una variabile d'ambiente che dipende dalla topologia di rete del deploy per poter identificare qualcuno in modo approssimativo. Se domani metto una CDN davanti, il numero cambia e il sistema di ban sbaglia in silenzio. Non fallisce: sbaglia.
Perché l'IP non funziona come identità
Oltre a essere falsificabile dietro al proxy sbagliato, l'IP è una chiave pessima per ragioni che non hanno rimedio. Quattro persone dello stesso ufficio ne condividono una. Una persona al cellulare cambia IP uscendo dal portone. Le reti mobili fanno NAT su blocchi enormi: bannare un IP di un operatore è bannare un intero quartiere.
Quello che vuoi da un identificatore è che sia stabile per la stessa persona e diverso per persone diverse. L'IP non soddisfa nessuna delle due. Uno user_id le soddisfa entrambe per costruzione.
Quello che puoi fare solo quando sai chi è chi
Nel motore di ricerca sono finito con tre livelli e un limite finito in tutti e tre:
visitor: { semanticSearchLimit: 10, historyLimit: 0 }
registered: { semanticSearchLimit: 30, historyLimit: 10 }
premium: { semanticSearchLimit: 200, historyLimit: null }
Il commento che ho lasciato nel tipo è tutto il criterio: sempre finito; nessun livello è esente dalla quota di ricerca. Il premium ha il numero più alto, non l'infinito. historyLimit ammette null per "illimitato". Quello di ricerca no, perché ogni ricerca è una fattura.
Il visitatore continua a esistere, con un cookie che gli dà una chiave anonima. Ma la finestra di rinnovo è diversa a seconda di chi sei: il registrato rinnova per mese solare in UTC, il premium rinnova all'anniversario dell'iscrizione. Questo impone di salvare un premiumAnchorAt, e salvare qualunque cosa impone una riga con un proprietario. Senza account non c'è nessun anniversario da ancorare.
Il caso delle app con costo per richiesta
Quando ogni azione costa soldi veri, la quota smette di essere una difesa e diventa contabilità. E la contabilità sbaglia in modi noiosi.
Nel motore di ricerca, due richieste identiche venivano contate due volte. La soluzione è stata una prenotazione con l'hash del contenuto della richiesta, non del suo testo: la query normalizzata, il limite, il flag di contenuto esplicito e i filtri ordinati. Il commento del tipo dice perché l'ordine conta — lo stesso insieme scelto in un altro ordine è la stessa ricerca — e perché i filtri stanno dentro: lo stesso testo con un altro genere è un'altra richiesta, e fonderli restituirebbe risultati sbagliati. La prenotazione restituisce reserved, duplicate, in_progress o exhausted. Tutti e quattro gli stati esistono perché tutti e quattro sono successi.
In un'API di trascrizione, il calcolo a caratteri era all'inizio trascrizione più testo elaborato diviso dieci. Misurando il costo reale, la riga è diventata questa:
transcriptionText.length + Math.floor(processedText.length * 0.25)
Da /10 a * 0.25: due volte e mezzo più caro. Il prezzo era sbagliato di un fattore 2,5 e per mesi ho pagato la differenza senza saperlo, perché non avevo la misurazione per accorgermene.
In un SaaS di generazione di immagini, il bug è stato il contrario: i crediti giornalieri esistevano ma il controllo preventivo guardava solo subCredits. Gli utenti con credito giornaliero ricevevano un 402 pur avendo credito sul conto. Un dailyCredits + subCredits l'ha sistemato. Tutte queste sono colonne di una riga utente. Senza riga, non c'è nessun bug da sistemare perché non c'è niente da contare.
Quando l'identità non basta
Avere un account non vuol dire avere un'identità valida. In un'API con chiavi, la riga della key sopravviveva allo stato dell'account del proprietario: in fase di offboarding si ripuliva il record di fatturazione senza che nessuno toccasse l'isActive della key. La key continuava ad autenticare. La correzione è stata rivalidare dentro lo stesso authenticateApiKey che il proprietario abbia ancora un record di fatturazione, invece di fidarsi del flag:
if (!ownerBilling) {
return { authenticated: false, error: "API key owner is no longer active" };
}
Va lì e non in ogni endpoint, proprio perché nessun endpoint debba ricordarselo.
Quando invece lascio entrare senza account
Quando l'azione non costa niente e non accumula stato: leggere una scheda, guardare un catalogo, caricare una landing. E un livello visitatore con una quota corta, perché qualcuno possa provare prima di decidere. Dieci ricerche prima della registrazione sono attrito onesto; zero sono una fattura aperta.
La domanda non è se il login dà fastidio. Dà fastidio. La domanda è cosa scriverai nel WHERE quando qualcuno esagera.
