Docs/Sicurezza
// sicurezza
Budget mensile su OpenAI
Nel repo c'è libs/gpt.ts: un helper minimale che chiama le chat completions di OpenAI. È comodo e pericoloso per lo stesso motivo — una riga di codice e stai spendendo soldi veri, con una chiave che di default non ha nessun tetto.
Cosa fa libs/gpt.ts
La funzione esportata è sendOpenAi(messages, userId, max, temp). Fa una POST diretta con axios, senza SDK:
const body = JSON.stringify({
model: 'gpt-4',
messages,
max_tokens: max,
temperature: temp,
user: userId,
});
const options = {
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
};Tre cose da sapere prima di usarla:
- Il modello è fisso a
gpt-4. È uno dei modelli più cari del listino: se stai facendo classificazione o riassunti, cambialo con un modello piccolo e la bolletta si divide per dieci. maxè ilmax_tokensdella risposta e vale 100 di default. Quel parametro è il tuo freno a mano: non farlo mai arrivare dal client.- In caso di errore la funzione logga e restituisce
null, non lancia. Quindi un fallimento è silenzioso: chi la chiama deve controllare il valore di ritorno, altrimenti mostrerà una pagina vuota senza sapere perché.
La chiave non è nel .env.example
Ho controllato: .env.example contiene solo le sezioni Resend, Supabase e Stripe. OPENAI_API_KEY non c'è: se usi libs/gpt.ts devi aggiungerla a mano in .env.local (e tra le variabili del progetto Vercel):
# OpenAI — serve solo se usi libs/gpt.ts
OPENAI_API_KEY=sk-proj-...Metti un tetto di spesa duro, subito
Si fa sulla dashboard OpenAI, non nel codice, e ci vogliono due minuti. Su platform.openai.com, nelle impostazioni di organizzazione, trovi i limiti di spesa:
- Limite hard mensile: superata quella cifra, le API smettono di rispondere. È l'unica cosa che ti salva davvero. Mettilo a una cifra che sei disposto a perdere in una notte.
- Soglia di alert: una cifra più bassa che ti manda solo un'email. Serve ad accorgerti dell'anomalia mentre c'è ancora margine.
- Un project dedicato: crea un project separato per questa app e genera lì la chiave. Le chiavi di project si revocano da sole senza toccare gli altri tuoi lavori, e puoi dare al project un suo budget.
Non usare mai la chiave “personale” dell'account per un'app in produzione. Quando (non se) dovrai revocarla, vuoi che il danno finisca lì.
Perché una chiave senza tetto è un problema serio
Le chiavi non scappano per magia. Scappano perché uno committa .env.local per sbaglio, perché finiscono in un log di build, o perché — il caso più comune — esiste un endpoint pubblico della tua app che le usa senza chiedere niente a nessuno. In tutti e tre i casi il risultato è lo stesso: qualcuno rivende accesso a gpt-4 a spese tue e tu te ne accorgi dalla fattura. Il tetto mensile trasforma un disastro in una serata storta.
Chiudi l'endpoint dietro auth
Oggi nel repo nessuna route chiama sendOpenAi — l'helper c'è ma non è cablato da nessuna parte. Quando lo colleghi, il file va creato e la prima cosa che deve fare è controllare chi sta chiamando. Questo file non c'è nel repo: crealo.
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { createClient } from "@/libs/supabase/server";
import { sendOpenAi } from "@/libs/gpt";
const schema = z.object({
prompt: z.string().min(1).max(2000),
});
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
// 401 -> libs/api.ts rimanda l'utente a config.auth.loginUrl
if (!user) {
return NextResponse.json({ error: "Devi accedere." }, { status: 401 });
}
const { data: profile } = await supabase
.from("profiles")
.select("has_access")
.eq("id", user.id)
.single();
// 403 -> libs/api.ts mostra "Pick a plan to use this feature"
if (!profile?.has_access) {
return NextResponse.json({ error: "Serve un piano attivo." }, { status: 403 });
}
const parsed = schema.safeParse(await req.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Prompt non valido." }, { status: 400 });
}
const answer = await sendOpenAi(
[{ role: "user", content: parsed.data.prompt }],
// sendOpenAi tipizza userId come number, ma gli id Supabase sono uuid:
// o fai il cast, o cambi la firma in libs/gpt.ts (consigliato).
user.id as any,
// max_tokens deciso dal server. Mai dal body della richiesta.
300
);
// sendOpenAi restituisce null quando la chiamata fallisce
if (!answer) {
return NextResponse.json({ error: "Il modello non ha risposto." }, { status: 502 });
}
return NextResponse.json({ answer });
}I codici 401 e 403 non sono casuali: l'interceptor in libs/api.ts li gestisce già: sul 401 fa redirect(config.auth.loginUrl), sul 403 mostra il toast “Pick a plan to use this feature”. Usa quei due status e l'esperienza utente arriva gratis.
Il campo user serve a qualcosa
sendOpenAi passa user: userId nel body: OpenAI lo usa per tracciare gli abusi per singolo utente finale. Passaci sempre l'id reale dell'utente Supabase — se un tuo account viene abusato, è quello che ti permette di isolarlo invece di spegnere tutto.
Auth non basta da sola
Un utente pagante può comunque, per errore o per malizia, mandarti diecimila richieste. Sopra l'auth metti un rate limit per user.id: vedi Rate limiting: API route. E se vuoi un tetto per utente, la stessa tecnica del contatore mensile descritta in Limiti di invio email (Resend) funziona identica sui token consumati — libs/gpt.ts logga già usage.total_tokens dopo ogni risposta.