🏢 Multi-tenant: isolamento por cliente
Sem multi-tenant, você não vende SaaS — vende projeto. Multi-tenant bem feito permite cobrar 10 clientes no mesmo backend e escalar margem sem proporcionalmente escalar custo.
Shared schema
Uma tabela contacts pra todos. Filtra por orgId. Mais simples, melhor custo, requer RLS rigoroso.
Schema por tenant
Cada cliente em schema/database próprio. Isolamento físico. Operação muito mais cara.
Silo (deploy isolado)
Cada cliente tem instância completa. Boa pra enterprise, péssima pra micro-SaaS BR.
🧬 Mapeamento Clerk Org → InboxAI orgId
📌 A regra de ouro multi-tenant
Toda query do backend filtra por orgId. Sempre. Sem exceção. Se você se pegar escrevendo db.query("contacts").collect() sem o filtro, é bug de segurança grave. Crie um helper scoped(ctx).query(...) que injeta orgId automaticamente do usuário autenticado — e nunca use db.query direto.
🔑 Auth com Clerk
Auth caseira é receita pra vazamento. Clerk resolve em horas o que levaria semanas — e libera você pra trabalhar no que diferencia o produto, não no login.
🧰 O que Clerk entrega out-of-the-box
📄 convex/auth.config.ts
TypeScriptexport default {
providers: [
{
domain: process.env.CLERK_JWT_ISSUER_DOMAIN,
applicationID: "convex",
},
],
};
📄 Webhook Clerk → cria org no Convex
TypeScript// convex/http.ts
http.route({
path: "/webhooks/clerk", method: "POST",
handler: httpAction(async (ctx, req) => {
const sig = req.headers.get("svix-signature");
const payload = await req.text();
if (!verifyClerkSignature(payload, sig, process.env.CLERK_WEBHOOK_SECRET!))
return new Response("invalid", { status: 401 });
const event = JSON.parse(payload);
if (event.type === "organization.created") {
await ctx.runMutation(internal.orgs.create, {
clerkOrgId: event.data.id,
name: event.data.name,
plan: "free",
});
}
if (event.type === "organizationMembership.created") {
await ctx.runMutation(internal.users.create, {
clerkOrgId: event.data.organization.id,
clerkUserId: event.data.public_user_data.user_id,
email: event.data.public_user_data.identifier,
role: event.data.role.replace("org:", ""),
});
}
return new Response("ok");
}),
});
📌 Roles que importam no InboxAI
owner (mexe em billing, deleta org) · admin (gerencia usuários, configura agente) · agent (atende conversas, sem billing). Clerk Organizations já entrega esses 3 — você só checa em cada mutation se a role permite.
🛡️ Row-level security em Convex
RLS quebrada vira manchete: "SaaS X vazou dados de 200 clínicas". É a defesa final contra erro humano de query mal escrita.
📄 convex/lib/scoped.ts (helper RLS)
TypeScriptimport { QueryCtx, MutationCtx } from "../_generated/server";
export async function requireOrgId(
ctx: QueryCtx | MutationCtx
): Promise<Id<"organizations">> {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("unauthenticated");
const user = await ctx.db
.query("users")
.withIndex("by_clerk", q => q.eq("clerkId", identity.subject))
.unique();
if (!user) throw new Error("user not found");
return user.orgId;
}
export async function requireRole(
ctx: MutationCtx,
allowed: ("owner" | "admin" | "agent")[]
) {
const orgId = await requireOrgId(ctx);
const identity = await ctx.auth.getUserIdentity();
const user = await ctx.db.query("users")
.withIndex("by_clerk", q => q.eq("clerkId", identity!.subject)).unique();
if (!allowed.includes(user!.role)) throw new Error("forbidden");
return { orgId, user };
}
📄 Uso em cada query/mutation
TypeScript// convex/conversations.ts
export const list = query({
args: { status: v.string() },
handler: async (ctx, { status }) => {
const orgId = await requireOrgId(ctx); // ← BLINDA
return await ctx.db
.query("conversations")
.withIndex("by_org_status", q => q.eq("orgId", orgId).eq("status", status))
.order("desc")
.take(50);
},
});
export const close = mutation({
args: { conversationId: v.id("conversations") },
handler: async (ctx, { conversationId }) => {
const { orgId } = await requireRole(ctx, ["owner", "admin", "agent"]);
const conv = await ctx.db.get(conversationId);
if (!conv || conv.orgId !== orgId) throw new Error("not found"); // ← double check
await ctx.db.patch(conversationId, { status: "closed" });
},
});
📊 Equivalente em Supabase (SQL policy)
-- habilita RLS na tabela alter table contacts enable row level security; -- política: usuário só vê linhas da sua org create policy "tenant_isolation" on contacts for all using (org_id = (auth.jwt() ->> 'org_id')::uuid) with check (org_id = (auth.jwt() ->> 'org_id')::uuid);
💡 Defense in depth
Tenha 3 camadas protegendo: (1) helper de query que filtra por orgId, (2) double-check do orgId no documento retornado antes de mutar, (3) testes automatizados que tentam acessar dados de outra org e devem falhar. Se uma quebrar, as outras seguram — é a paranoia que evita manchete.
🔒 Variáveis de ambiente: secrets corretamente
Secret no Git é a forma mais comum de invasão. 5 minutos de checklist evita boleto de R$ 50k da AWS por mineração de cripto na sua conta vazada.
📋 Checklist de secret hygiene
.env e .env.local no .gitignore (commitado).env.example sem valores reais, só placeholdersnpx convex env setgitleaks rodando em pre-commit hookNEXT_PUBLIC_ só pra valores não-sensíveis📄 Comandos de gerenciamento
bash# Convex Cloud — setar secret em produção npx convex env set ANTHROPIC_API_KEY sk-ant-... # listar todos npx convex env list # Vercel — setar pra prod vercel env add CLERK_SECRET_KEY production # scan local antes de commit gitleaks detect --source . --verbose # verificar se .env está realmente ignorado git check-ignore -v .env # se vazou: revogue IMEDIATAMENTE no painel do provedor, # rode git filter-repo pra limpar do histórico, # rotacione TODOS os secrets do mesmo .env (assuma que vazaram juntos) git filter-repo --path .env --invert-paths
⚠️ Secret vazou — protocolo de incidente
Em 5 minutos: revogue a chave no painel do provedor (não tente "trocar depois"). Em 30 minutos: rotacione TODOS os secrets do mesmo .env — assuma que vazaram juntos. Em 24h: auditoria de uso da chave (pode ter virado bot de cripto). Em 7 dias: post-mortem honesto pro cliente afetado, se houver.
🚀 Deploy: Vercel + Convex Cloud
Cliente paga por produto online, não por código no GitHub. Em 3 comandos seu InboxAI está ao vivo, com preview de cada PR e rollback de 1 clique.
🛤️ Pipeline completa
📄 Setup inicial (uma vez)
bash# 1. logar nos serviços vercel login npx convex login # 2. linkar repo vercel link # 3. deploy do Convex pra produção (cria URL prod) npx convex deploy # 4. exportar URL pro Next consumir vercel env add NEXT_PUBLIC_CONVEX_URL production # (cola a URL https://serious-fox-123.convex.cloud) # 5. primeiro deploy vercel --prod # Daqui pra frente: git push main → CI/CD automático
🔄 Rollback
# Vercel — rollback do frontend (1 clique no painel ou): vercel rollback <deployment-id> # Convex — rollback de funções (mantém dado) npx convex deployments npx convex deploy --rollback <deploy-id>
📌 Preview por PR é o segredo
Cada PR no GitHub gera URL única tipo inbox-ai-pr-42.vercel.app conectada a um Convex preview deployment com banco isolado. Você manda esse link pro cliente revisar antes de mergear. Feature aprovada por cliente vai pra prod sem surpresa. Esse fluxo é o que faz cliente sentir que está dirigindo o produto junto.
🌐 Domínio próprio + SSL automático
Cliente sério não compra .vercel.app. Compra app.suamarca.com.br. Domínio próprio é o que separa MVP de produto vendável.
🛍️ Onde comprar (Brasil)
- • R$ 40/ano
- • Exige CPF/CNPJ BR
- • Confiança máxima local
- • Preço de custo
- • DNS rápido global
- • Zero markup, sem upsell
- • Mais caro
- • Setup zero clique
- • Bom pra economizar tempo
🔧 Apontar domínio externo pra Vercel
app.suamarca.com.brapp apontando pra cname.vercel-dns.com🏷️ Subdomínio por cliente (white-label)
# Wildcard CNAME no DNS
*.app.com.br CNAME cname.vercel-dns.com
# Vercel: Add wildcard domain
# *.app.com.br
# Next middleware lê hostname e passa pra app
// middleware.ts
export function middleware(req) {
const host = req.headers.get("host"); // clinica-vita.app.com.br
const subdomain = host.split(".")[0]; // clinica-vita
req.headers.set("x-tenant", subdomain);
}
💡 O efeito psicológico do .com.br
Cliente brasileiro PME confia mais em .com.br do que em .com. Pra produto vendendo no Brasil, registre o .com.br mesmo se já tiver .com. Custa R$ 40/ano e dobra a credibilidade percebida em prospect que liga pra ver se "é coisa séria mesmo".
✅ O que Aprendemos
Próxima Trilha:
Trilha 5 — Sistemas Multiagente: orquestrar múltiplos agentes especializados, comunicação entre eles, supervisor pattern e quando vale a pena complicar.