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