Uno scale-up SaaS sono sei audit e sei fix in un ordine fisso. L'ordine conta perché le superfici si compongono: rate limit sopra una RLS rotta perde comunque dati; cache sopra un billing rotto perde comunque soldi; osservabilità sopra una cache rotta racconta comunque nulla di utile. Il lavoro è meccanico una volta fissato l'ordine; il valore sta nell'ordine.
I cinque file qui sotto sono l'ossatura che l'ingaggio lascia in piedi. Il test di isolamento tenant, l'handler webhook Stripe, il middleware di rate limit, il wrapper di cache, e il bootstrap di osservabilità. Ogni file è piccolo. Ogni file è il posto dove il team mette le mani quando il sistema deve cambiare.
1. Il test di isolamento tenant
Il test fa login come utente A, prova a leggere le risorse dell'utente B su ogni route API, e fa fallire la build su qualsiasi risposta non vuota. Seminiamo due utenti all'inizio del test e li smontiamo alla fine; il test è ermetico. Gira a ogni PR.
// tests/integration/tenant-isolation.spec.ts
import { test, expect } from '@playwright/test'
import { createClient } from '@supabase/supabase-js'
const ROUTES_THAT_RETURN_TENANT_DATA = [
{ method: 'GET', path: '/api/projects' },
{ method: 'GET', path: '/api/projects/:id' },
{ method: 'GET', path: '/api/invoices' },
{ method: 'GET', path: '/api/team' },
]
test('user A cannot read user B data', async ({ request }) => {
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE!)
const userA = await supabase.auth.admin.createUser({ email: 'a@test', password: 'a' })
const userB = await supabase.auth.admin.createUser({ email: 'b@test', password: 'b' })
const { data: signIn } = await supabase.auth.signInWithPassword({ email: 'a@test', password: 'a' })
const tokenA = signIn!.session!.access_token
// Semina un progetto per l'utente B
await supabase.from('projects').insert({ owner: userB.data.user!.id, name: 'B project' })
for (const route of ROUTES_THAT_RETURN_TENANT_DATA) {
const res = await request.fetch(route.path, {
method: route.method,
headers: { Authorization: `Bearer ${tokenA}` },
})
const body = await res.json()
expect(body.data ?? body, `route ${route.path} ha fatto uscire dati tenant`).toEqual([])
}
await supabase.auth.admin.deleteUser(userA.data.user!.id)
await supabase.auth.admin.deleteUser(userB.data.user!.id)
})
2. L'handler webhook Stripe
L'handler è una sola route, fa switch sul tipo di evento, scrive ogni evento in una tabella events con l'ID evento Stripe come idempotency key. Un webhook in replay è un no-op. Un handler che lancia errore viene riprovato da Stripe; la tabella eventi registra il retry. Non modifichiamo mai lo stato cliente senza una riga di evento.
// app/api/stripe/webhook/route.ts
import { headers } from 'next/headers'
import Stripe from 'stripe'
import { createClient } from '@/lib/supabase/server'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request): Promise<Response> {
const signature = (await headers()).get('stripe-signature') ?? ''
const body = await req.text()
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch (err) {
return new Response(`signature invalid: ${(err as Error).message}`, { status: 400 })
}
const supabase = createClient()
// Idempotenza: l'insert torna conflict se l'evento è stato già processato.
const { error: idempErr } = await supabase
.from('stripe_events')
.insert({ id: event.id, type: event.type, created: event.created })
if (idempErr?.code === '23505') {
return new Response('already processed', { status: 200 })
}
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionChange(supabase, event.data.object)
break
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(supabase, event.data.object)
break
case 'invoice.payment_failed':
await handlePaymentFailed(supabase, event.data.object)
break
case 'invoice.payment_succeeded':
await handlePaymentSucceeded(supabase, event.data.object)
break
case 'customer.deleted':
await handleCustomerDeleted(supabase, event.data.object)
break
}
return new Response('ok', { status: 200 })
}
3. Il middleware di rate limit
Un rate limiter sliding-window su Upstash Redis, con bucket per-IP e per-utente. Il middleware gira su ogni route pubblica. I limiti vivono in un file di config; alzarne uno è una code review, non una sessione redis-cli.
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const redis = Redis.fromEnv()
const RATE_LIMITS = {
default: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(60, '1 m'), prefix: 'rl:default' }),
auth: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, '1 m'), prefix: 'rl:auth' }),
write: new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(20, '1 m'), prefix: 'rl:write' }),
}
function pickLimit(path: string): keyof typeof RATE_LIMITS {
if (path.startsWith('/api/auth')) return 'auth'
if (path.startsWith('/api/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes('POST')) return 'write'
return 'default'
}
export async function middleware(req: NextRequest): Promise<NextResponse> {
if (!req.nextUrl.pathname.startsWith('/api/')) return NextResponse.next()
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
const limit = RATE_LIMITS[pickLimit(req.nextUrl.pathname)]
const result = await limit.limit(ip)
if (!result.success) {
return new NextResponse(
JSON.stringify({ success: false, error: 'rate_limited' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': String(Math.ceil((result.reset - Date.now()) / 1000)),
},
},
)
}
return NextResponse.next()
}
export const config = { matcher: '/api/:path*' }
4. Il wrapper di cache con stale-while-revalidate
Un helper tipizzato che avvolge ogni lettura cacheable. Torna dati cacheati subito quando fresh, torna dati stale e rinfresca in background quando si è dentro la finestra SWR, e cade attraverso al loader quando entrambe le finestre sono passate. Ogni cache hit e miss viene contato; la metrica va allo stack di osservabilità.
// lib/cache/cachedApiCall.ts
import { Redis } from '@upstash/redis'
const redis = Redis.fromEnv()
interface CacheEntry<T> {
value: T
fresh: number
stale: number
}
interface CacheOptions {
ttl: number // secondi
staleWhileRevalidate?: number // secondi extra in cui si serve stale mentre gira la rinfrescata in background
}
export async function cachedApiCall<T>(
key: string,
loader: () => Promise<T>,
options: CacheOptions,
): Promise<T> {
const now = Date.now()
const cached = await redis.get<CacheEntry<T>>(key)
if (cached && cached.fresh > now) {
return cached.value
}
if (cached && cached.stale > now) {
// Serve stale, rinfresca in background; non aspettare.
void refreshAndStore(key, loader, options).catch(() => undefined)
return cached.value
}
const fresh = await loader()
await refreshAndStore(key, async () => fresh, options)
return fresh
}
async function refreshAndStore<T>(
key: string,
loader: () => Promise<T>,
options: CacheOptions,
): Promise<void> {
const value = await loader()
const now = Date.now()
const entry: CacheEntry<T> = {
value,
fresh: now + options.ttl * 1000,
stale: now + (options.ttl + (options.staleWhileRevalidate ?? 0)) * 1000,
}
await redis.set(key, entry, { ex: options.ttl + (options.staleWhileRevalidate ?? 0) })
}
5. Il bootstrap di osservabilità
Un file solo cabla logging strutturato, error reporting e metriche. Il runtime lo importa una volta in cima a instrumentation.ts. Il team riceve i quattro golden signal il primo giorno; il resto cresce dalle stesse primitive.
// instrumentation.ts
import * as Sentry from '@sentry/nextjs'
import pino from 'pino'
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
redact: ['*.password', '*.token', 'authorization'],
formatters: {
level: (label) => ({ level: label }),
},
})
export async function register(): Promise<void> {
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.VERCEL_GIT_COMMIT_SHA,
tracesSampleRate: 0.1,
beforeSend(event) {
// Toglie PII dai breadcrumb prima di inviare.
if (event.request?.headers) {
delete event.request.headers.authorization
delete event.request.headers.cookie
}
return event
},
})
logger.info({ release: process.env.VERCEL_GIT_COMMIT_SHA }, 'instrumentation ready')
}
6. Cosa compone questo
Il test di tenant dimostra che i dati non escono. L'handler webhook dimostra che il billing non perde stato. Il rate limit dimostra che la superficie pubblica non si scioglie sotto abuso. La cache dimostra che il database non viene colpito a ogni page view. Il bootstrap dimostra che il team sa cosa sta succedendo quando qualcosa va storto.
L'MVP non è più un MVP. Il codebase è noioso nel modo specifico in cui è noioso un SaaS che scala: il lavoro succede nei file giusti, le metriche dicono la verità, la persona di reperibilità legge un runbook invece di chiamare il founder, e la prossima volta che la stampa pesca il prodotto, il sito resta su.