MÓDULO 4.4

🔐 Auth Multi-tenant + Deploy

Multi-tenant blindado, login com Clerk, RLS pra isolar clientes, secrets corretamente, Vercel + Convex Cloud em produção e domínio próprio com SSL no automático — InboxAI ao vivo na web.

6
Tópicos
75
Minutos
Avançado
Nível
Deploy
Tipo
1

🏢 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.

PADRÃO 1
🏞️

Shared schema

Uma tabela contacts pra todos. Filtra por orgId. Mais simples, melhor custo, requer RLS rigoroso.

✓ Recomendado pra InboxAI
PADRÃO 2
🏘️

Schema por tenant

Cada cliente em schema/database próprio. Isolamento físico. Operação muito mais cara.

! Apenas se compliance exigir
PADRÃO 3
🏰

Silo (deploy isolado)

Cada cliente tem instância completa. Boa pra enterprise, péssima pra micro-SaaS BR.

✗ Mata margem

🧬 Mapeamento Clerk Org → InboxAI orgId

Clerk Organization org_2YxK... "Clínica Vita" 3 membros webhook organizations _id: orgs:abc123 clerkOrgId: org_2YxK name: Clínica Vita FK contacts orgId: orgs:abc phone, name... (N rows)

📌 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.

2

🔑 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

📧
Magic link
🔵
Google OAuth
🐙
GitHub OAuth
🔐
2FA TOTP
🏢
Organizations
👥
Roles + invites
📦
User profile UI
🔔
Webhooks

📄 convex/auth.config.ts

TypeScript
export 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.

3

🛡️ 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)

TypeScript
import { 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.

4

🔒 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ó placeholders
Secrets na Vercel separados por env: development / preview / production
Secrets no Convex via npx convex env set
gitleaks rodando em pre-commit hook
Rotação automática de Clerk + Anthropic + Evolution a cada 90 dias
Variáveis client-side com prefixo NEXT_PUBLIC_ só pra valores não-sensíveis
Acesso aos paineis (Vercel/Convex/Clerk) com 2FA obrigatório

📄 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.

5

🚀 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

git push main Vercel build Next Convex deploy backend Edge CDN global Functions serverless app.cliente.com SSL automático 90 segundos do push até estar ao vivo · preview por PR · rollback 1-click

📄 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.

6

🌐 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)

Registro.br
.com.br oficial
  • • R$ 40/ano
  • • Exige CPF/CNPJ BR
  • • Confiança máxima local
Cloudflare
.com / .ai / .io
  • • Preço de custo
  • • DNS rápido global
  • • Zero markup, sem upsell
Vercel Domains
tudo no mesmo painel
  • • Mais caro
  • • Setup zero clique
  • • Bom pra economizar tempo

🔧 Apontar domínio externo pra Vercel

1
No painel Vercel: Settings → Domains → Add
Insere app.suamarca.com.br
2
No DNS do Registro.br/Cloudflare
Cria CNAME app apontando pra cname.vercel-dns.com
3
Aguarda propagação (5–60 min)
Vercel valida automaticamente e emite SSL via Let's Encrypt
Pronto: HTTPS ao vivo
SSL renova sozinho a cada 90 dias. Você nunca mais toca.

🏷️ 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

Shared schema com orgId em toda query é o padrão pro micro-SaaS BR — silo só se compliance exigir.
Clerk entrega magic link, OAuth, 2FA, organizations e roles em horas — webhook cria org no Convex.
RLS via helper requireOrgId em cada handler — defense in depth com 3 camadas.
Secrets corretamente: .env no gitignore, painel Vercel/Convex, gitleaks, rotação 90 dias — protocolo claro se vazar.
Vercel + Convex Cloud em 3 comandos com preview por PR — cliente revisa antes de mergear.
.com.br + SSL automático = produto vendável — wildcard subdomínio destrava white-label.

Próxima Trilha:

Trilha 5 — Sistemas Multiagente: orquestrar múltiplos agentes especializados, comunicação entre eles, supervisor pattern e quando vale a pena complicar.