Devlog · Fase 0 · 3/7
Recopilar direcciones de correo sin una sola tabla
aubia.dev recopila direcciones de correo sin base de datos. El contenido se almacena en ficheros planos, y cada dirección de correo se envía hacia aubia.cloud a través de un proxy firmado.
- architecture
- security
- waitlist
- gdpr

La lista de espera de aubia.dev recopila direcciones de correo, y el repositorio que la sirve no contiene una sola tabla. Ni un modelo, ni una migración: la carpeta de migraciones ni siquiera existe.
Retirar la base de datos de un sitio suprime de golpe toda una clase de preocupaciones: copias de seguridad, cifrado en reposo, fuga de datos, purga RGPD. No se asegura lo que no se posee.
Cada dirección de correo enviada atraviesa la memoria de un worker durante una llamada HTTP saliente, y luego desaparece. El servicio que la recibe, la confirma y la conserva es aubia.cloud.
Un dato personal solo pasa del sitio al cloud por un único flujo. Un proxy firmado transmite el envío al servicio remoto, adjuntándole una prueba de origen calculada in situ.
Ficheros planos para todo el contenido
Los textos del sitio se almacenan en ficheros de traducción JSON, los artículos de este blog en Markdown versionados junto al código. Esos ficheros planos se leen directamente del disco, sin un servidor de base de datos delante.
Seis locales y ninguna consulta SQL. El contenido se despliega con el repositorio, sin paso de migración.
Redis sigue en su sitio, para la sesión, una caché transitoria y el rate limiter. Este último pone un techo al número de envíos aceptados desde un mismo origen en una ventana de tiempo. Trabaja sobre un hash efímero de la dirección de correo y otro de la dirección IP, que caducan con la ventana a la que sirven.
La caché tiene una excepción: el índice del blog se escribe en ella sin fecha de caducidad. Contiene títulos, fechas y el HTML ya renderizado de artículos públicos, ningún dato personal.
El diagrama simplifica un poco: otros flujos cruzan la frontera, como la lectura pública del contador de confirmados. El proxy firmado es el único que transporta un dato personal.
Un controller sin escritura
Cuando usted envía su dirección de correo, el controller se mantiene delgado. Valida, coloca los campos en un objeto tipado, delega en una Action y devuelve un mensaje flash.
No conoce ni la red ni la firma, y no tiene ninguna base donde escribir. El método store() no hace nada más:
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());
}
}
La dirección de correo solo existe en memoria, lo que dura la petición. Ninguna línea se escribe del lado del sitio.
El secreto compartido y las trampas antirrobots
La Action SubmitWaitlistEmail toma el relevo. Serializa los campos que hay que transmitir, y luego calcula una firma HMAC SHA-256 del cuerpo obtenido, es decir una firma con clave compartida.
La firma del cuerpo enviado
Envía un POST a aubia.cloud, con una cabecera Origin fijada por contrato. El cuerpo serializado, el secreto y las cabeceras se construyen en ese orden:
$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),
];
La firma se calcula sobre el cuerpo exacto que sale, con un secreto que ambos lados conocen. El cloud vuelve a calcular la firma y compara.
El secreto nunca sale de esos dos servidores. Un bearer token, en cambio, viaja en cada llamada, y puede filtrarse en los registros si no se depura de ellos.
En local, la firma se desactiva con un flag de configuración, para probar el formulario sin montar toda la cadena del secreto. En producción, el envío falla si la firma está desactivada o si falta la clave, en lugar de partir en claro y en silencio.
Dos trampas y un campo excluido
El formulario detiene a los robots con dos trampas que un visitante nunca encuentra: un honeypot y un plazo mínimo. El artículo sobre la preinscripción las detalla.
Una de las dos toca el contrato saliente. El campo de marca de tiempo rendered_at sirve a la validación local y nunca llega al cloud, lo que mantiene mínima la superficie del contrato y estabiliza la firma.
El método de salida del DTO lo dice en su comentario y lo aplica en su 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,
];
}
La marca de tiempo es el único campo que el DTO transporta sin transmitirlo al cloud.
Esas dos trampas guardan el formulario, y solo el formulario. Un plazo mínimo lo sortea un robot cuidadoso, capaz de programar una espera, y un honeypot, un autómata que lee la hoja de estilos.
El contrato saliente se guarda de otro modo, con el secreto compartido. Sin él, ninguna petición forjada desde fuera es aceptada.
Reintentar una petición sin inscribir dos veces
Un cliente HTTP ingenuo repite toda petición fallida. La política del cliente saliente depende de lo que el sitio sabe del destino de la petición.
Una respuesta recibida, aunque sea un error de servidor, prueba que el cloud ha recibido la petición. Lo que ha hecho con ella sigue siendo desconocido, y el POST de inscripción nunca se reintenta en ese caso.
Un fallo de red, en cambio, no trae ninguna respuesta. La petición probablemente no ha alcanzado el cloud, y ese es el único caso en que se reintenta.
El cliente saliente decide, por tanto, petición por petición. Dos ejecuciones valen a veces exactamente lo mismo que una sola.
El GET del contador de confirmados se reintenta en todos los casos, incluido un error de servidor, ya que releer una cifra no inscribe a nadie. El POST de inscripción solo se reintenta ante un error de red, y la condición está escrita en el 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,
)
Solo una ConnectionException desencadena un nuevo intento. Si el cloud ha respondido, aunque sea con un error de servidor, el sitio mapea la respuesta a un estado de negocio y muestra el mensaje correspondiente.
El consentimiento del lado del cloud
aubia.cloud genera el token de confirmación, envía el correo, hace caducar el enlace tras 48 horas y conserva el rastro del consentimiento.
Mientras una inscripción no esté confirmada, no sale ninguna otra comunicación. Las direcciones de correo nunca confirmadas se purgan tras 30 días, la duración que publica la página de privacidad.
El servidor del sitio nunca ve ese token. Las páginas de confirmación y de baja lo hacen transitar por el navegador, en llamada directa al cloud, sin volver a pasar por él. El resto del recorrido, del campo de correo al contador de confirmados, se cuenta en otro artículo.
Tres cookies y una clave de tema
Todo lo que el servidor renderiza es idéntico para todos los visitantes, con una excepción: la raíz. / redirige hacia /fr, /en u otra de las seis locales, según una cookie de preferencia y después la cabecera Accept-Language.
Esa cookie, aubia_locale, lleva un código de locale, nada más. Otras dos la acompañan: XSRF-TOKEN para el token CSRF, que protege de los envíos forjados desde otro sitio, y aubia-session para la sesión de Laravel.
La preferencia de tema se guarda en el localStorage, bajo la clave aubia-theme. El localStorage es el espacio que el navegador reserva a un sitio dado, y su contenido no viaja en ninguna petición.
Estas tres cookies son técnicas, ninguna mide la audiencia ni sirve a la publicidad. Exentas de consentimiento, ahorran al sitio un banner.
El coste y la ganancia de la ausencia
Un secreto compartido se declara en dos repositorios, con su rotación que orquestar. Cada inscripción depende de la red. Si aubia.cloud no responde, el formulario lo dice, en lugar de escribir en algún sitio a la espera de días mejores.
El contador de confirmados no tiene ninguna tabla que interrogar. Llama al endpoint público de lectura del cloud, guarda el resultado en caché unas decenas de segundos y conserva un último valor conocido como recurso ante una avería.
Redis sigue siendo necesario para la sesión, la caché y el rate limiter. Algo de estado permanece, efímero.
Enfrente, el despliegue no ejecuta ninguna migración. El sitio no tiene ninguna copia de seguridad que probar, ningún cifrado en reposo que auditar, ninguna dirección de correo que purgar. Las 48 horas del token y los 30 días de retención se cuentan del lado del cloud, en el único lugar que posee algo.
Los límites de la decisión
Decido entre esas dos listas: una dependencia de red y una clave que rotar de un lado, cuatro frentes de cumplimiento que no existen del otro.
Esta decisión no se traslada a una aplicación que deba servir a sus usuarios cuando la red cae. Tampoco se sostendría en un sitio que creciera hasta poseer otra cosa que contenido público.
En su perímetro actual, el sitio no necesita ninguna tabla.
Esta obra se cuenta aquí, artículo a artículo. Lo que viene después depende de lo que usted diga de ella.
Únase a la lista de espera