Aller au contenu principal

Journal de bord · Phase 0 · 3/7

Une vitrine sans base de données

Zéro table, zéro donnée personnelle au repos : la waitlist d'aubia.dev part vers aubia.cloud via un proxy signé HMAC, avec un retry qui ne double jamais une inscription.

Publié le 27 juillet 20266 min de lecture
  • architecture
  • security
  • waitlist
  • gdpr

La landing d'Aubia collecte des adresses email. On pourrait croire qu'elle a donc besoin d'une base de données. Elle n'en a aucune. Pas de table, pas de modèle Eloquent, pas une seule adresse email écrite sur disque côté vitrine. Je n'ai pas subi cette absence, je l'ai posée dès le premier commit. Une page qui inscrit des gens à une liste d'attente peut ne rien persister du tout, et c'est plus simple ainsi.

Une vitrine n'a pas besoin de base de données

Tout le contenu d'aubia.dev est statique par nature : les textes marketing vivent dans des fichiers de traduction JSON, les articles de ce blog dans des fichiers Markdown versionnés avec le code. Rien de tout cela ne justifie une base de données. Le seul flux dynamique de la Phase 0, l'inscription à la waitlist, ne stocke rien localement : il transite vers aubia.cloud.

Le repo web n'embarque donc aucun modèle Eloquent, aucune migration de données métier. Ce qui touche Redis se limite à du cache transitoire et à la session : un compteur, un jeton anti-abus, jamais une adresse email durable.

Retirer la base de données d'une vitrine, c'est retirer d'un coup toute une classe de préoccupations : sauvegardes, chiffrement au repos, fuite de données, purge RGPD. On ne sécurise pas ce qu'on ne détient pas.

La frontière entre aubia.dev, vitrine publique sans état, et api.aubia.cloud qui détient le stockage waitlist, les tokens et la trace du consentement ; seul un POST signé HMAC la traverse.

Zéro donnée personnelle stockée côté web

La règle est absolue : toute donnée personnelle passe par le cloud, jamais par un stockage local côté vitrine. Quand vous soumettez votre email, le controller reste volontairement mince. Il valide, construit un objet de transport typé, délègue à une Action, et renvoie un message flash. Il ne connaît ni le réseau, ni la signature, ni la base de données, parce qu'il n'y en a pas.

final class WaitlistController
{
    public function store(SubmitWaitlistRequest $request, SubmitWaitlistEmail $action): RedirectResponse
    {
        $data = WaitlistSubmissionData::fromArray([
            'email' => $request->validated()['email'],
            'locale' => $request->validated()['locale'],
            // ... utm et honeypot temporel
        ]);

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

        return $status->isSuccess()
            ? back()->with('waitlist_success', $status->messageKey())
            : back()->with('waitlist_error', $status->messageKey());
    }
}

L'email traverse la mémoire du worker le temps d'un appel HTTP sortant, puis disparaît. Rien ne se dépose.

Un proxy signé vers aubia.cloud

Le module Waitlist a rejoint le repo début mai 2026, une fois le décor de la landing posé. Son Action SubmitWaitlistEmail fait le pont. Elle sérialise le payload, calcule une signature HMAC SHA-256 et poste vers aubia.cloud. La signature prouve au cloud que la requête vient bien de la vitrine officielle, et l'origine est figée dans le contrat.

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

$headers = [
    'Content-Type' => 'application/json',
    'Origin' => 'https://aubia.dev',
];

if (config('services.cloud.waitlist_proxy_sign_hmac') !== false) {
    $secret = (string) config('services.cloud.waitlist_proxy_signing_key');
    $headers['X-Aubia-Signature'] = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
}

J'ai volontairement gardé cette signature minimale : elle porte sur le corps JSON brut, sans horodatage ni nonce anti-rejeu. La vitrine est le seul appelant légitime et son origine est restreinte par IP côté cloud, ce qui rend une attaque par rejeu peu réaliste en Phase 0. Ajouter une mécanique anti-rejeu aurait alourdi le contrat sans bénéfice tangible à ce stade.

En production, cette signature ne peut pas être désactivée : le code lève une exception si quelqu'un tente de la couper. Un détail compte dans le payload envoyé. Le formulaire piège les robots avec un honeypot temporel, un champ started_at posé au montage de la page. Ce champ sert uniquement à la validation locale et n'est jamais transmis au cloud, ce qui garde la surface du contrat minimale et stabilise la signature.

public function toCloudPayload(): array
{
    return [
        'email' => $this->email,
        'utm_source' => $this->utmSource,
        'utm_medium' => $this->utmMedium,
        'utm_campaign' => $this->utmCampaign,
        'locale' => $this->locale,
    ];
    // started_at (honeypot) volontairement exclu
}

La signature n'est pas là pour remplacer les pièges anti-bot mais pour ajouter une couche par-dessus. Un honeypot ou un délai minimal se contournent par un robot un peu soigné, capable de lire le DOM ou de programmer une attente. Le HMAC, lui, rend le scraping de masse coûteux : sans le secret partagé, aucune requête forgée n'est acceptée.

Un retry qui ne double jamais une inscription

Le réseau échoue parfois. Un client HTTP naïf rejouerait alors la requête, au risque d'inscrire deux fois la même personne. Le client sortant distingue donc deux cas selon l'idempotence du verbe. Un GET, comme la lecture du compteur, peut être rejoué sans effet de bord. Un POST d'inscription, non : il ne rejoue que si la requête n'a jamais atteint le cloud.

->retry(
    $retries + 1,
    fn(int $attempt): int => $backoffMs + random_int(0, 100),
    // Retry UNIQUEMENT sur erreur réseau : la requête n'a jamais atteint
    // le cloud. Jamais sur une réponse reçue, même 5xx (double inscription).
    fn(Throwable $e, PendingRequest $request): bool => $e instanceof ConnectionException,
    throw: false,
)

Si le cloud a répondu, même par une erreur serveur, la vitrine ne réessaie pas : elle mappe la réponse sur un statut métier et affiche le bon message. La correction se fait dans la sémantique du retry, pas au petit bonheur.

Le double opt-in vit côté cloud

C'est aubia.cloud qui gère le double opt-in : génération du token de confirmation, envoi de l'email, expiration après 48 heures, et surtout la trace du consentement. Tant qu'une inscription n'est pas confirmée, aucune communication n'est envoyée, et les adresses non confirmées sont purgées automatiquement.

La vitrine ne voit jamais rien de tout cela. Elle ne connaît ni le token, ni l'état de confirmation, ni la moindre donnée durable. Cette frontière nette entre un front public sans état et un cloud qui détient la logique et les données est ce qui rend l'ensemble simple à raisonner et à sécuriser.

Ce que l'absence de base de données simplifie

Sans base de données locale, le déploiement d'aubia.dev devient trivial : pas de migration à jouer, pas de connexion à provisionner, pas d'état à répliquer entre workers. La sécurité se réduit d'autant, car la vitrine ne détient aucun secret utilisateur à protéger. Et la conformité RGPD se déplace à un seul endroit, le cloud, au lieu d'être éparpillée sur chaque surface.

Une vitrine doit informer et convertir, pas devenir un coffre-fort. En lui retirant sa base de données, je lui retire ses responsabilités les plus lourdes.

Ce proxy signé n'est qu'un maillon. Le parcours complet, lui, mérite son propre récit : le formulaire, l'anti-bot sans captcha, le double opt-in et le compteur, avec les arbitrages derrière chacun de ces choix.

Pour suivre la construction d'Aubia et être prévenu à l'ouverture de la beta 0.1, rejoignez la liste d'attente.

Ce chantier se raconte ici, article après article. Embarquez dans l'aventure : vos retours dessineront la suite.

Rejoindre la liste d'attente