Cada busca semântica de um dos meus projetos custa dinheiro: um embedding, uma chamada de reranking e uma síntese de texto. Centavos por requisição. Multiplicado por um bot com um loop, não são centavos. A primeira versão não pedia conta para nada, porque pedir conta para buscar anime parecia absurdo. Continua parecendo. Faço assim mesmo.
A razão não é marketing nem lista de e-mails. É que sem identidade não há nada para contar.
O custo de deixar entrar sem identificar
Montei um harness de validação fake-door com cinco landings ao mesmo tempo para medir interesse antes de construir qualquer coisa. A única coisa que precisava ser protegida era o painel de admin, então coloquei banimento de IP após várias falhas de login. Suficiente, pensei.
Não era. O IP do cliente vinha de x-forwarded-for, e esse cabeçalho é uma lista cliente, proxy1, proxy2… em que cada proxy acrescenta à direita mas ninguém limpa o que já estava escrito. Pegar o primeiro valor é pegar o que o cliente quiser escrever. Qualquer um manda x-forwarded-for: 1.2.3.4, escapa do próprio banimento, e de quebra pode banir o IP de outra pessoa escrevendo o dela.
Consertei contando saltos em vez de confiar na esquerda:
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];
Com um proxy na frente, o cliente real é o último valor: o que o proxy escreve e o cliente não pode tocar. Funciona. Mas repare no que eu acabei de fazer: coloquei uma variável de ambiente que depende da topologia de rede do deploy para poder identificar alguém de forma aproximada. Se amanhã eu colocar um CDN na frente, o número muda e o sistema de banimentos erra em silêncio. Não falha: erra.
Por que o IP não serve como identidade
Além de ser falsificável atrás do proxy errado, o IP é uma chave ruim por razões que não têm conserto. Quatro pessoas do mesmo escritório compartilham um. Uma pessoa no celular muda de IP ao sair de casa. As redes móveis fazem NAT sobre blocos enormes: banir um IP de operadora é banir um bairro inteiro.
O que você quer de um identificador é que seja estável para a mesma pessoa e diferente para pessoas diferentes. O IP não cumpre nenhuma das duas. Um user_id cumpre as duas por construção.
O que você só consegue fazer quando sabe quem é quem
No buscador acabei com três níveis e um limite finito nos três:
visitor: { semanticSearchLimit: 10, historyLimit: 0 }
registered: { semanticSearchLimit: 30, historyLimit: 10 }
premium: { semanticSearchLimit: 200, historyLimit: null }
O comentário que deixei no tipo é o critério inteiro: sempre finito; nenhum nível está isento da cota de busca. O premium tem o número mais alto, não o infinito. historyLimit aceita null para "ilimitado". O de busca não, porque cada busca é uma fatura.
O visitante continua existindo, com um cookie que lhe dá uma chave anônima. Mas a janela de renovação é diferente conforme quem você é: o registrado renova por mês-calendário em UTC, o premium renova no aniversário da assinatura. Isso exige guardar um premiumAnchorAt, e guardar qualquer coisa exige uma linha com dono. Sem conta não há aniversário para ancorar.
O caso dos apps com custo por requisição
Quando cada ação custa dinheiro real, a cota deixa de ser uma defesa e passa a ser contabilidade. E a contabilidade falha de formas chatas.
No buscador, duas requisições idênticas eram contadas duas vezes. A solução foi uma reserva com hash do conteúdo da requisição, não do seu texto: a query normalizada, o limite, a flag de explícito e os filtros ordenados. O comentário do tipo diz por que a ordem importa — o mesmo conjunto escolhido em outra ordem é a mesma busca — e por que os filtros vão dentro: o mesmo texto com outro gênero é outra requisição, e fundi-los devolveria resultados errados. A reserva devolve reserved, duplicate, in_progress ou exhausted. Os quatro estados existem porque os quatro aconteceram.
Numa API de transcrição, a cobrança por caracteres começou sendo transcrição mais texto processado dividido por dez. Ao medir o custo real, a linha virou isto:
transcriptionText.length + Math.floor(processedText.length * 0.25)
De /10 para * 0.25: duas vezes e meia mais caro. O preço estava errado por um fator de 2,5 e durante meses eu paguei a diferença sem saber, porque não tinha a medição para perceber.
Num SaaS de geração de imagens, o bug foi o contrário: os créditos diários existiam mas a checagem prévia só olhava subCredits. Os usuários com crédito diário recebiam um 402 com crédito na conta. Um dailyCredits + subCredits resolveu. Tudo isso são colunas de uma linha de usuário. Sem linha, não há bug para consertar porque não há nada para contar.
Quando a identidade também não basta
Ter conta não é ter identidade válida. Numa API com chaves, a linha da key sobrevivia ao estado da conta do dono: o registro de faturamento era limpo no offboarding sem que ninguém tocasse no isActive da key. A key continuava autenticando. O conserto foi revalidar no próprio authenticateApiKey que o dono continua tendo registro de faturamento, em vez de confiar na flag:
if (!ownerBilling) {
return { authenticated: false, error: "API key owner is no longer active" };
}
Vai ali e não em cada endpoint, justamente para que nenhum endpoint precise lembrar.
Quando eu deixo entrar sem conta
Quando a ação não custa nada e não acumula estado: ler uma ficha, ver um catálogo, carregar uma landing. E um nível de visitante com cota curta, para que alguém possa experimentar antes de decidir. Dez buscas antes do cadastro é atrito honesto; zero é uma fatura aberta.
A pergunta não é se o login incomoda. Incomoda. A pergunta é o que você vai escrever no WHERE quando alguém passar do ponto.
