Ir para o conteúdo principal

Devlog · Fase 0 · 3/7

Coletar endereços de e-mail sem uma única tabela

aubia.dev coleta endereços de e-mail sem banco de dados. O conteúdo é armazenado em arquivos planos, e cada endereço de e-mail é enviado para aubia.cloud por um proxy assinado.

Publicado em 27 de julho de 2026Atualizado em 3 de setembro de 20268 min de leitura
  • architecture
  • security
  • waitlist
  • gdpr

A lista de espera de aubia.dev coleta endereços de e-mail, e o repo que a serve não contém uma única tabela. Nem um model, nem uma migration: a pasta das migrations nem sequer existe.

Retirar o banco de dados de uma vitrine suprime de uma vez toda uma classe de preocupações: backups, criptografia em repouso, vazamento de dados, expurgo RGPD. Não se protege o que não se detém.

Cada endereço de e-mail enviado atravessa a memória de um worker pelo tempo de uma chamada HTTP de saída, e depois desaparece. O serviço que o recebe, o confirma e o guarda é aubia.cloud.

Um dado pessoal só passa da vitrine ao cloud por um único fluxo. Um proxy assinado transmite o envio ao serviço remoto, juntando a ele uma prova de origem calculada no local.

Arquivos planos para todo o conteúdo

Os textos da vitrine são armazenados em arquivos de tradução JSON, os artigos deste blog em Markdown versionados com o código. Esses arquivos planos são lidos diretamente do disco, sem servidor de banco de dados na frente.

Seis locales e nenhuma consulta SQL. O conteúdo é implantado com o repo, sem etapa de migration.

O Redis continua no lugar, para a sessão, um cache transitório e o rate limiter. Este último limita o número de envios aceitos de uma mesma origem em uma janela de tempo. Ele trabalha sobre um hash efêmero do endereço de e-mail e outro do endereço IP, que expiram junto com a janela que eles servem.

O cache tem uma exceção: o índice do blog é escrito nele sem data de expiração. Ele contém títulos, datas e o HTML já renderizado de artigos públicos, nenhum dado pessoal.

A fronteira entre aubia.dev, vitrine pública sem estado, e api.aubia.cloud, que detém o armazenamento da lista de espera, os tokens e o rastro do consentimento; apenas um POST assinado com HMAC a atravessa.

O diagrama simplifica um pouco: outros fluxos atravessam a fronteira, como a leitura pública do contador de confirmados. O proxy assinado é o único que transporta um dado pessoal.

Um controller sem escrita

Quando você envia seu endereço de e-mail, o controller permanece magro. Ele valida, coloca os campos em um objeto tipado, delega a uma Action e devolve uma mensagem flash.

Ele não conhece nem a rede nem a assinatura, e não tem nenhuma base onde escrever. O método store() não faz mais nada:

final class WaitlistController
{
    public function store(SubmitWaitlistRequest $request, SubmitWaitlistEmail $action): RedirectResponse
    {
        $validated = $request->validated();

        $data = WaitlistSubmissionData::fromArray([
            'email' => $validated['email'],
            'utm_source' => $validated['utm_source'] ?? null,
            'utm_medium' => $validated['utm_medium'] ?? null,
            'utm_campaign' => $validated['utm_campaign'] ?? null,
            'rendered_at' => (int) $validated['rendered_at'],
            'locale' => $validated['locale'],
        ]);

        $status = $action->execute($data);

        if ($status->isSuccess()) {
            return back()->with('waitlist_success', $status->messageKey());
        }

        return back()->with('waitlist_error', $status->messageKey());
    }
}

O endereço de e-mail só existe em memória, pelo tempo da requisição. Nenhuma linha é escrita do lado da vitrine.

O segredo compartilhado e as armadilhas antirrobô

A Action SubmitWaitlistEmail assume em seguida. Ela serializa os campos a transmitir, e depois calcula uma assinatura HMAC SHA-256 do corpo obtido, ou seja, uma assinatura de chave compartilhada.

A assinatura do corpo enviado

Ela posta para aubia.cloud, com um cabeçalho Origin fixado por contrato. O corpo serializado, o segredo e os cabeçalhos se constroem nesta ordem:

$rawBody = json_encode($data->toCloudPayload(), JSON_THROW_ON_ERROR);

$secret = (string) config('services.cloud.signing_key');

$headers = [
    'Content-Type' => 'application/json',
    'Accept' => 'application/json',
    'Origin' => 'https://aubia.dev',
    'X-Signature' => 'sha256=' . hash_hmac('sha256', $rawBody, $secret),
];

A assinatura é calculada sobre o corpo exato que parte, com um segredo que os dois lados conhecem. O cloud repete o mesmo cálculo e compara.

O segredo nunca sai desses dois servidores. Um bearer token, esse, viaja em cada chamada, e pode vazar nos registros se não for mascarado ali.

No ambiente local, a assinatura se desativa por uma flag de configuração, para testar o formulário sem montar toda a cadeia do segredo. Em produção, o envio falha se a assinatura está desativada ou a chave ausente, em vez de postar em claro e em silêncio.

Duas armadilhas e um campo excluído

O formulário detém os robôs por duas armadilhas que um visitante nunca encontra: um honeypot e um atraso mínimo. O artigo sobre a pré-inscrição as detalha.

Uma das duas toca no contrato de saída. O campo de marca temporal rendered_at serve à validação local e nunca chega ao cloud, o que mantém a superfície do contrato mínima e estabiliza a assinatura.

O método de saída do DTO o diz em seu comentário e o aplica em seu array:

public function toCloudPayload(): array
{
    // rendered_at (piège temporel) volontairement exclu.
    return [
        'email' => $this->email,
        'utm_source' => $this->utmSource,
        'utm_medium' => $this->utmMedium,
        'utm_campaign' => $this->utmCampaign,
        'locale' => $this->locale,
    ];
}

A marca temporal é o único campo que o DTO transporta sem repassá-lo ao cloud.

Essas duas armadilhas guardam o formulário, e só o formulário. Um atraso mínimo se contorna com um robô caprichado, capaz de programar uma espera, e um honeypot com um autômato que lê a folha de estilo.

O contrato de saída se guarda de outro modo, pelo segredo compartilhado. Sem ele, nenhuma requisição forjada de fora é aceita.

Repetir uma requisição sem inscrever duas vezes

Um cliente HTTP ingênuo repete toda tentativa malsucedida. A política do cliente de saída depende do que a vitrine sabe sobre o que aconteceu com a requisição.

Uma resposta recebida, mesmo um erro de servidor, prova que o cloud recebeu a requisição. O que ele fez com ela continua desconhecido, e o POST de inscrição nunca se repete nesse caso.

Uma falha de rede, essa, não traz resposta alguma. A requisição provavelmente não alcançou o cloud, e é o único caso em que ela é repetida.

O cliente de saída decide, portanto, requisição por requisição. Duas execuções às vezes valem exatamente o mesmo que uma só.

O GET do contador de confirmados se retenta em todos os casos, inclusive sobre um erro de servidor, já que reler um número não inscreve ninguém. O POST de inscrição só se repete sobre uma falha de rede, e a condição está escrita no cliente:

->retry(
    $retries + 1,
    fn(int $attempt): int => $backoffMs + random_int(0, 100),
    // Retry UNIQUEMENT sur erreur réseau : aucune réponse reçue, et le cloud
    // absorbe un doublon éventuel. Jamais sur une réponse reçue, même 5xx.
    fn(Throwable $e, PendingRequest $request): bool => $e instanceof ConnectionException,
    throw: false,
)

Só uma ConnectionException dispara uma nova tentativa. Se o cloud respondeu, mesmo com um erro de servidor, a vitrine mapeia a resposta para um status de negócio e exibe a mensagem correspondente.

O consentimento do lado do cloud

aubia.cloud gera o token de confirmação, envia o e-mail, faz o link expirar após 48 horas e conserva o rastro do consentimento.

Enquanto uma inscrição não é confirmada, nenhuma outra comunicação parte. Os endereços de e-mail nunca confirmados são expurgados após 30 dias, o prazo que a página de privacidade publica.

O servidor da vitrine nunca vê esse token. As páginas de confirmação e de cancelamento o fazem transitar pelo navegador, em chamada direta ao cloud, sem passar de novo por ele. O resto do percurso, do campo de e-mail ao contador de confirmados, está contado em outro artigo.

Três cookies e uma chave de tema

Tudo o que o servidor renderiza é idêntico para todos os visitantes, com uma exceção: a raiz. / redireciona para /fr, /en ou outra das seis locales, segundo um cookie de preferência e depois o cabeçalho Accept-Language.

Esse cookie, aubia_locale, carrega um código de locale, nada mais. Dois outros o acompanham: XSRF-TOKEN para o token CSRF, que protege de envios forjados a partir de outro site, e aubia-session para a sessão do Laravel.

A preferência de tema é armazenada no localStorage, sob a chave aubia-theme. O localStorage é a reserva que o navegador mantém para um dado site, e seu conteúdo não parte em requisição alguma.

Esses três cookies são técnicos, nenhum deles mede audiência nem serve à publicidade. Isentos de consentimento, dispensam a vitrine de um banner.

O custo e o ganho da ausência

Um segredo compartilhado é declarado em dois repos, com sua rotação a orquestrar. Cada inscrição depende da rede. Se aubia.cloud não responde, o formulário o diz, em vez de escrever em algum lugar à espera de dias melhores.

O contador de confirmados não tem nenhuma tabela a consultar. Ele chama o cloud em leitura pública, guarda o resultado em cache por algumas dezenas de segundos e conserva um último valor conhecido como contingência.

O Redis continua exigido para a sessão, o cache e o rate limiter. Um pouco de estado permanece, efêmero.

Do outro lado, o deploy não executa nenhuma migration. A vitrine não tem nenhum backup a testar, nenhuma criptografia em repouso a auditar, nenhum endereço de e-mail a expurgar. As 48 horas do token e os 30 dias de retenção se contam do lado do cloud, no único lugar que detém alguma coisa.

Os limites da escolha

Decido entre essas duas listas: uma dependência de rede e uma chave a rotacionar de um lado, quatro frentes de conformidade que não existem do outro.

Essa escolha não se transpõe a uma aplicação que precisa servir seus usuários quando a rede cai. Ela também não se sustentaria em uma vitrine que crescesse até deter outra coisa além de conteúdo público.

No perímetro que é o dela hoje, a vitrine não precisa de nenhuma tabela.

Esta construção é contada aqui, artigo após artigo. O que vem a seguir depende do que você disser sobre ela.

Entrar na lista de espera