Skip to content

feat(gdpr): armer la file — la route qui peut enfin supprimer un compte - #284

Merged
thierryvm merged 4 commits into
mainfrom
feat/3b-b-cron-arming
Jul 27, 2026
Merged

feat(gdpr): armer la file — la route qui peut enfin supprimer un compte#284
thierryvm merged 4 commits into
mainfrom
feat/3b-b-cron-arming

Conversation

@thierryvm

@thierryvm thierryvm commented Jul 27, 2026

Copy link
Copy Markdown
Owner

⛔ Merger est sûr. Poser le secret ne l'est pas encore.

Ce merge n'arme rien : sans CRON_SECRET dans Vercel, la route répond 401 et rien n'est supprimé. C'est la pose du secret qui arme.

Elle est bloquée par #285 — blocage de tête de file : 25 lignes dont l'effacement échoue affameraient la file pour toujours, pendant que l'écran dit à la personne « la suppression a commencé, elle ne peut plus être annulée » et lui retire le bouton d'annulation. Pire que le bug d'origine, qui était au moins réversible.

Et par deux lectures production : la n° 5 (combien de comptes le premier run détruirait-il ?) et la n° 4 (privilèges des 9 fonctions sur l'hébergé), toutes deux dans PR-3B-B-report.md.

Ordre verrouillé : merge → amendement ADR-024 + correctif #285 → les deux lectures → alors seulement poser le secret → redéployervercel crons ls → run manuel file vide. La dernière étape est indivisible : les variables Vercel sont figées dans un déploiement, poser sans redéployer donne un 401 quotidien silencieux.

PR-A (#282) a livré la file inerte. Celle-ci lui donne son unique appelant.

src/app/api/cron/gdpr/route.ts devient le seul endroit du système capable de détruire un compte. Sa revue ne porte donc que sur une question : est-ce que ça peut partir quand il ne faut pas ?

Décisions : ADR-024 · Plan : step-3b §B1-B5.

La route refuse par défaut

Cas Réponse
Aucun en-tête Authorization 401, avant toute E/S
En-tête qui ne commence pas par Bearer 401
Jeton vide 401
Mauvais jeton, même longueur 401
Mauvais jeton, longueur différente 401 — et surtout pas 500
CRON_SECRET absent de l'environnement 401, plus un log.error

Le cas des longueurs différentes n'est pas cosmétique. timingSafeEqual rejette les buffers de longueurs inégales — il lève. Comparer les jetons bruts transformerait une mauvaise longueur en 500, et le code de statut suffirait alors à dire à un attaquant quand il a deviné la bonne longueur. Les deux côtés passent par SHA-256, donc chaque comparaison fait 32 octets contre 32 octets.

Une panne de configuration doit crier ; un mauvais jeton doit rester muet. Les deux rendent 401 — indiscernables côté appelant — et la différence ne vit que dans nos logs. Sans cette branche, un cron incapable de s'authentifier ressemblerait exactement à un attaquant qu'on éconduit.

L'invariant qui lie deux fichiers

maxDuration = 60 doit rester inférieur au seuil de reprise d'1 h de claim_pending_deletions(). Les deux valeurs forment un couple : si un run vivant dépasse le seuil, le run suivant lui vole son lot et le même compte est supprimé deux fois. C'est écrit dans la route, dans la migration, et un test le garde.

Ce qui se passe quand ça rate

Chaque erreur d'effacement est isolée. Vercel ne réessaie jamais un cron : lever ici abandonnerait tous les comptes suivants jusqu'au lendemain. La ligne reste processing et repasse en file une heure plus tard.

Le journal d'échec porte le request_id, jamais le user_id — l'écrire remettrait l'identifiant dans un log durable une ligne après avoir entrepris de l'effacer.

capped ne sert pas à limiter le travail : il sert à rendre visible le jour où 25 demandes arrivent d'un coup. Il émet donc aussi un log.error, sans quoi il ne servirait à rien.

Pas de rateLimit(), et pourquoi

rate-limit.ts échoue fermé en production. Une panne Upstash bloquerait donc l'exercice d'un droit RGPD, pour protéger un endpoint déjà couvert par un secret de 32 octets à comparaison constante et invoqué une fois par jour. Résidu accepté et nommé dans ADR-024 : endpoint public non compté, sur un plan où l'invocation est la ressource rare — le 401 avant toute E/S borne le coût au CPU.

La purge a enfin un appelant

purge_audit_log_older_than_12_months() existait depuis avril sans que rien ne l'appelle. La politique de confidentialité promettait un plafond de 12 mois que rien n'appliquait (art. 5(1)(e)). PR-A l'avait réparée (SECURITY DEFINERINVOKER, après avoir mesuré qu'en DEFINER elle pouvait supprimer 0 ligne sans lever) ; src/lib/gdpr/retention.ts la branche.

30 → 14 jours (ADR-023)

Livré avec l'exécuteur et jamais avant : publier une fenêtre plus courte sans rien pour l'honorer serait pire que la situation actuelle. À 30 jours, l'effacement tombait au bord exact du délai légal d'un mois de l'art. 12(3) — un run en échec et on était hors délai.

6 sites hors i18n (dont README.md et SECURITY.md, dépôt public), 25 chaînes i18n (5 clés × 5 locales), llms-full.txt régénéré, et le test de date.

Une surévaluation corrigée au passage

app.settings.danger.description promettait « après, tout est effacé », dans les 5 locales. C'est vrai tant que rien n'efface — et faux le jour où le cron s'arme, puisque auth.audit_log_entries survit avec l'e-mail en clair (#278). La copie dit maintenant ce qui est réellement effacé, et ce qui subsiste.

Preuves

Falsification — 4 mutations de la route, 4 rouges sur le test qui les garde :

Mutation Test devenu rouge
Comparaison sans SHA-256 des deux côtés 401s on a wrong secret of a DIFFERENT length
log.error du secret manquant retiré 401s when CRON_SECRET is missing — and SCREAMS
user_id ajouté au journal d'échec never puts a user id in the failure log
Plafond testé avec > au lieu de >= flags capped AND logs an error

Restauré : 16 passed. Suite complète : 1761 passed / 136 fichiers.

La spec e2e publique exerce les refus contre un vrai serveur HTTP, pas seulement en import de fonction — 4 cas, mesurés en local avant push.

Plancher e2e public : 215 → 224 (9 = 3 cas × 3 projets), mesuré.

Annoncé à +12, puis redescendu. silent-failure-auditor a mesuré que CRON_SECRET n'est défini dans aucun bloc env de ci.yml : ces cas sortent par la première branche de la route et n'atteignent jamais la comparaison de secret — remplacer le corps de secretMatches par return true les laissait tous verts. Un quatrième cas affirmait que les deux refus sont indiscernables ; en CI ils sont littéralement la même branche, l'assertion ne pouvait pas échouer. Retiré plutôt que laissé à ressembler à un garde-fou.

Un plancher bâti sur un cas qui ne peut pas échouer inspire une confiance qu'il ne mérite pas. Les neuf restants prouvent une chose vraie et non triviale : la route refuse par défaut, sur un vrai socket.

Reste dû après merge — la vérification d'armement

Un cron qu'on n'a jamais vu répondre 200 en production n'est pas livré. §B5 du plan :

  1. CRON_SECRET posé dans Vercel (≥ 32 octets, généré localement — jamais dans une URL, jamais via un outil MCP).
  2. vercel crons ls → la tâche apparaît réellement armée.
  3. Déclenchement manuel file vide{ claimed: 0 } et 200.

Ce que ce merge rend vrai, et qu'il faut dire

L'écart art. 17 de l'issue #278 naît aujourd'hui. auth.audit_log_entries conserve l'e-mail en clair et l'IP, sans clé étrangère vers auth.users, et survit à l'effacementservice_role ne peut même pas la lire. Une personne qui exerce son droit verra son compte supprimé et son adresse rester en base. Ce n'était pas vrai hier parce que rien n'effaçait ; ça l'est à partir du premier run.

🤖 Generated with Claude Code

PR-B de l'étape 3B. PR-A (#282) a livré le schéma inerte ; ce commit lui donne
son unique appelant. C'est désormais le SEUL endroit du système capable de
détruire un compte, et sa revue ne porte que sur une question : est-ce que ça
peut partir quand il ne faut pas ?

LA ROUTE (src/app/api/cron/gdpr/route.ts), 401 par défaut :
  - en-tête absent, ne commençant pas par `Bearer `, jeton vide ou faux -> 401
    AVANT toute E/S, ce qui borne aussi le coût d'un endpoint public non compté
  - SHA-256 des deux côtés PUIS timingSafeEqual sur deux digests de 32 octets.
    timingSafeEqual REJETTE les longueurs inégales — il lève. Comparer les
    jetons bruts transformerait une mauvaise longueur en 500, et le code de
    statut fuiterait la longueur attendue.
  - CRON_SECRET absent de l'environnement -> 401 AUSSI, mais avec log.error.
    Une panne de configuration doit crier, un mauvais jeton rester muet ; les
    deux sont indiscernables côté appelant.
  - chaque erreur d'effacement est ISOLÉE : Vercel ne réessaie jamais un cron,
    donc lever ici abandonnerait tous les comptes suivants jusqu'au lendemain.
    La ligne reste `processing` et repasse en file une heure plus tard.
  - le journal d'échec porte le request_id, JAMAIS le user_id : l'écrire
    remettrait l'identifiant dans un log durable une ligne après avoir entrepris
    de l'effacer.
  - `capped` ne sert pas à limiter le travail, il sert à RENDRE VISIBLE le jour
    où 25 demandes arrivent d'un coup — donc il émet aussi un log.error.
  - réponse en compteurs seuls, aucune donnée personnelle.

Pas de rateLimit() : rate-limit.ts échoue FERMÉ en production, donc une panne
Upstash bloquerait l'exercice d'un droit RGPD pour protéger un endpoint déjà
couvert par un secret de 32 octets à comparaison constante, invoqué une fois par
jour. Résidu accepté et nommé dans ADR-024.

INVARIANT ÉCRIT DANS LES DEUX FICHIERS : maxDuration (60 s) doit rester
INFÉRIEUR au seuil de reprise d'1 h de claim_pending_deletions. Qui portera ce
nombre à 300 s touche à la protection anti-double-suppression, pas à un timeout.

LA PURGE A ENFIN UN APPELANT. purge_audit_log_older_than_12_months() existait
depuis avril sans que rien ne l'appelle : la politique de confidentialité
promettait un plafond de 12 mois que rien n'appliquait. src/lib/gdpr/retention.ts
ferme l'écart art. 5(1)(e).

30 -> 14 JOURS (ADR-023), livré AVEC l'exécuteur et jamais avant : publier une
fenêtre plus courte sans rien pour l'honorer serait pire que la situation
actuelle. 6 sites hors i18n + 25 chaînes (5 clés x 5 locales) + llms-full.txt
régénéré + le test de date.

Et une surévaluation corrigée dans les 5 locales : « après, tout est effacé »
devient faux le jour où le cron s'arme, puisque auth.audit_log_entries survit
avec l'e-mail en clair (issue #278). La copie dit maintenant ce qui est
réellement effacé, et ce qui subsiste.

FALSIFICATION : 4 mutations de la route, 4 rouges sur le test qui les garde —
comparaison sans SHA-256, log.error du secret manquant retiré, user_id ajouté au
journal d'échec, plafond testé avec > au lieu de >=. Restauré : 16 passed.
Suite complète 1761 passed. Plancher e2e public mesuré 215 -> 227.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ankora Ready Ready Preview, Comment Jul 27, 2026 6:17pm

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @thierryvm, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@github-actions github-actions Bot added status:review-needed Ready for review type:feat New user-facing feature labels Jul 27, 2026
@sourcery-ai

sourcery-ai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Guide du réviseur

Implémente et connecte l’endpoint cron de suppression de compte GDPR comme unique appelant du pipeline de suppression, introduit un appelant de purge du journal d’audit à 12 mois, renforce le comportement de sécurité/opérationnel autour du cron (auth, isolation des erreurs, invariants) et réduit la période de grâce de suppression de compte de 30 à 14 jours dans le code, les tests et la documentation/i18n.

Diagramme de séquence pour l’exécution cron de suppression de compte GDPR

sequenceDiagram
  participant VercelCron
  participant ApiCronGdpr as ApiCronGdprRoute
  participant Deletion as GdprDeletion
  participant Retention as GdprRetention

  VercelCron->>ApiCronGdpr: GET /api/cron/gdpr (Authorization?)
  alt missing_or_wrong_CRON_SECRET
    ApiCronGdpr->>ApiCronGdpr: log.error CRON_SECRET missing (config failure)
    ApiCronGdpr-->>VercelCron: 401 unauthorized
  else authenticated
    ApiCronGdpr->>Deletion: claimPendingDeletions(BATCH_SIZE)
    alt claim_error
      ApiCronGdpr->>ApiCronGdpr: log.error Failed to claim pending deletions
      ApiCronGdpr-->>VercelCron: 500 { error: claim_failed }
    else claimed_batch
      loop for each request
        ApiCronGdpr->>Deletion: executeDeletion(userId)
        alt deletion_error
          ApiCronGdpr->>ApiCronGdpr: log.error Account erasure failed (request_id)
        end
      end
      ApiCronGdpr->>Retention: purgeAuditLogOlderThan12Months()
      alt purge_error
        ApiCronGdpr->>ApiCronGdpr: log.error Audit log retention purge failed
      end
      ApiCronGdpr-->>VercelCron: 200 { claimed, deleted, failed, purged, capped }
    end
  end
Loading

Modifications au niveau des fichiers

Changement Détails Fichiers
Ajouter la route /api/cron/gdpr comme unique point d’entrée exécutable pour les suppressions de compte GDPR, avec une authentification stricte par jeton Bearer, une comparaison en temps constant, un traitement par lots, de la journalisation et l’intégration de la purge du journal d’audit.
  • Définir une route Node.js, Next.js force-dynamic qui s’authentifie via un jeton Bearer CRON_SECRET et refuse par défaut avec un 401 avant toute E/S
  • Utiliser un helper SHA-256 plus timingSafeEqual pour comparer les secrets en temps constant sans lever d’exception en cas de longueurs différentes
  • Réserver (claim) les suppressions en attente par lots bornés, exécuter les suppressions avec isolation des erreurs par requête, et suivre les compteurs supprimé/échoué sans exposer de PII dans les réponses ou les journaux
  • Appeler la RPC purge_audit_log_older_than_12_months à la fin de l’exécution, en avalant les erreurs après journalisation afin qu’elles n’affectent pas les résultats de suppression
  • Mettre en place une limite de taille de lot avec un journal d’erreur associé lorsqu’elle est atteinte, et n’exposer que des compteurs agrégés dans la réponse de l’API
src/app/api/cron/gdpr/route.ts
Introduire des tests pour verrouiller le comportement d’auth du cron, les sémantiques opérationnelles et les invariants, et exercer l’endpoint sur un HTTP réel en e2e.
  • Ajouter des tests unitaires qui couvrent tous les cas de refus d’auth, le comportement en cas de configuration manquante (avec journalisation d’erreur), le chemin de succès, l’isolation des échecs, le comportement de purge du journal d’audit, la limitation par lots et l’invariant maxDuration par rapport au seuil de claim obsolète
  • S’assurer que les journaux d’échec contiennent uniquement des IDs de requête (aucun ID utilisateur) et que les réponses ne contiennent jamais de PII
  • Ajouter des tests e2e Playwright qui appellent /api/cron/gdpr via HTTP pour vérifier le comportement 401 en cas de jeton manquant/erroné, le 401 constant pour des jetons de mauvaise longueur, et des corps d’erreur indiscernables entre mauvaise configuration et secret incorrect
  • Mettre à jour CLAUDE.md pour augmenter le seuil minimal documenté des tests e2e Playwright publics afin de refléter les nouvelles spécifications
src/app/api/cron/gdpr/__tests__/route.test.ts
e2e/cron-gdpr-auth.spec.ts
CLAUDE.md
Connecter et exposer un appelant en rôle de service pour la RPC de purge du journal d’audit à 12 mois utilisée par le cron.
  • Créer un helper de rétention qui appelle purge_audit_log_older_than_12_months via un client Supabase en rôle de service, interprète sa valeur de retour comme un nombre de lignes supprimées et traduit les erreurs en exceptions levées
  • Inclure ce helper dans la route cron et propager son résultat dans la réponse sous forme de compteur purged
src/lib/gdpr/retention.ts
src/app/api/cron/gdpr/route.ts
Rendre CRON_SECRET typé, optionnel, en variable d’environnement serveur, dont l’absence est gérée à l’exécution par un 401 avec une journalisation forte plutôt que par un échec de validation de schéma ou de CI.
  • Ajouter CRON_SECRET comme chaîne optionnelle de longueur minimale 32 dans le schéma Zod des variables d’environnement serveur, avec une documentation expliquant pourquoi elle est optionnelle même en production
  • Compter sur des vérifications à l’exécution dans la route cron pour émettre un 401 et journaliser une erreur lorsque CRON_SECRET est absent, au lieu de faire échouer les builds ou les workflows
src/lib/env.ts
Réduire la période de grâce de suppression de compte GDPR de 30 à 14 jours et aligner en conséquence les tests, la documentation d’architecture, le README, la sécurité/docs et le texte de l’interface, y compris corriger une exagération concernant ce que la suppression retire.
  • Modifier GRACE_PERIOD_DAYS de 30 à 14 dans le wrapper d’orchestration de suppression et mettre à jour sa justification légale dans les commentaires
  • Ajuster les tests de planification de suppression pour vérifier une fenêtre de 14 jours au lieu de 30 jours
  • Mettre à jour la documentation d’architecture et de qualité produit, le README, SECURITY et les commentaires d’actions pour décrire une période de grâce de 14 jours et un cron quotidien
  • Actualiser llms-full.txt et les fichiers de messages i18n afin que les chaînes visibles par l’utilisateur et la documentation publique reflètent la nouvelle promesse de 14 jours et décrivent avec précision quelles données sont supprimées vs conservées
src/lib/gdpr/deletion.ts
src/lib/gdpr/__tests__/deletion.test.ts
docs/ARCHITECTURE.md
docs/ankora-product-quality-bar-v1.md
README.md
SECURITY.md
src/lib/actions/settings.ts
messages/de-DE.json
messages/en.json
messages/es-ES.json
messages/fr-BE.json
messages/nl-BE.json
public/llms-full.txt
Mettre à jour la mise en forme de la documentation, des tableaux et de la feuille de route pour améliorer la cohérence et refléter le nouveau comportement GDPR et les nouveaux baselines de tests.
  • Normaliser l’alignement et l’espacement des colonnes de tableaux dans les sections de barre de qualité produit et de README pour le vocabulaire, les zones du tableau de bord, les agents et les jalons de feuille de route
  • Documenter le nouveau planning du cron et le comportement de suppression dans la documentation de qualité produit et de feuille de route, et s’assurer que les références aux agents de suppression/export sont à jour
  • Laisser un placeholder dans .env.example/vercel.json pour les modifications de configuration liées au cron si certaines ont été introduites dans PR-A et sont utilisées ici
docs/ankora-product-quality-bar-v1.md
README.md
CLAUDE.md
docs/ARCHITECTURE.md
vercel.json
.env.example

Conseils et commandes

Interagir avec Sourcery

  • Déclencher une nouvelle revue : Commentez @sourcery-ai review sur la pull request.
  • Poursuivre les discussions : Répondez directement aux commentaires de revue de Sourcery.
  • Générer un ticket GitHub à partir d’un commentaire de revue : Demandez à Sourcery de créer un ticket à partir d’un commentaire de revue en y répondant. Vous pouvez également répondre à un commentaire de revue avec @sourcery-ai issue pour créer un ticket à partir de celui-ci.
  • Générer un titre de pull request : Écrivez @sourcery-ai n’importe où dans le titre de la pull request pour générer un titre à tout moment. Vous pouvez également commenter @sourcery-ai title sur la pull request pour (re)générer le titre à tout moment.
  • Générer un résumé de pull request : Écrivez @sourcery-ai summary n’importe où dans le corps de la pull request pour générer un résumé de PR à tout moment exactement à l’endroit souhaité. Vous pouvez également commenter @sourcery-ai summary sur la pull request pour (re)générer le résumé à tout moment.
  • Générer un guide du réviseur : Commentez @sourcery-ai guide sur la pull request pour (re)générer le guide du réviseur à tout moment.
  • Résoudre tous les commentaires Sourcery : Commentez @sourcery-ai resolve sur la pull request pour résoudre tous les commentaires Sourcery. Utile si vous avez déjà traité tous les commentaires et ne souhaitez plus les voir.
  • Ignorer toutes les revues Sourcery : Commentez @sourcery-ai dismiss sur la pull request pour ignorer toutes les revues Sourcery existantes. Particulièrement utile si vous voulez repartir de zéro avec une nouvelle revue – n’oubliez pas de commenter @sourcery-ai review pour déclencher une nouvelle revue !

Personnaliser votre expérience

Accédez à votre dashboard pour :

  • Activer ou désactiver des fonctionnalités de revue telles que le résumé de pull request généré par Sourcery, le guide du réviseur, et d’autres.
  • Changer la langue de revue.
  • Ajouter, supprimer ou modifier des instructions de revue personnalisées.
  • Ajuster d’autres paramètres de revue.

Obtenir de l’aide

Original review guide in English

Reviewer's Guide

Implements and wires the GDPR account-deletion cron endpoint as the sole caller of the deletion pipeline, introduces a 12‑month audit-log purge caller, tightens security/operational behavior around the cron (auth, error isolation, invariants), and reduces the account-deletion grace period from 30 to 14 days across code, tests, and docs/i18n.

Sequence diagram for the GDPR cron account-deletion run

sequenceDiagram
  participant VercelCron
  participant ApiCronGdpr as ApiCronGdprRoute
  participant Deletion as GdprDeletion
  participant Retention as GdprRetention

  VercelCron->>ApiCronGdpr: GET /api/cron/gdpr (Authorization?)
  alt missing_or_wrong_CRON_SECRET
    ApiCronGdpr->>ApiCronGdpr: log.error CRON_SECRET missing (config failure)
    ApiCronGdpr-->>VercelCron: 401 unauthorized
  else authenticated
    ApiCronGdpr->>Deletion: claimPendingDeletions(BATCH_SIZE)
    alt claim_error
      ApiCronGdpr->>ApiCronGdpr: log.error Failed to claim pending deletions
      ApiCronGdpr-->>VercelCron: 500 { error: claim_failed }
    else claimed_batch
      loop for each request
        ApiCronGdpr->>Deletion: executeDeletion(userId)
        alt deletion_error
          ApiCronGdpr->>ApiCronGdpr: log.error Account erasure failed (request_id)
        end
      end
      ApiCronGdpr->>Retention: purgeAuditLogOlderThan12Months()
      alt purge_error
        ApiCronGdpr->>ApiCronGdpr: log.error Audit log retention purge failed
      end
      ApiCronGdpr-->>VercelCron: 200 { claimed, deleted, failed, purged, capped }
    end
  end
Loading

File-Level Changes

Change Details Files
Add the /api/cron/gdpr route as the only executable entrypoint for GDPR account deletions, with strict Bearer-token auth, constant-time comparison, batch processing, logging, and audit-log purge integration.
  • Define a Node.js, force-dynamic Next.js route that authenticates via a CRON_SECRET Bearer token and refuses by default with 401 before any I/O
  • Use a SHA-256 plus timingSafeEqual helper to compare secrets in constant time without throwing on differing lengths
  • Claim pending deletions in bounded batches, execute deletions with per-request error isolation, and track deleted/failed counts without exposing PII in responses or logs
  • Invoke the purge_audit_log_older_than_12_months RPC at the end of the run, swallowing failures after logging so they do not affect deletion results
  • Implement a batch-size cap with an accompanying error log when hit, and expose only aggregate counts in the API response
src/app/api/cron/gdpr/route.ts
Introduce tests to lock down cron auth behavior, operational semantics, and invariants, and to exercise the endpoint over real HTTP in e2e.
  • Add unit tests that cover all auth refusal cases, configuration-missing behavior (with error logging), success path, failure isolation, audit-log purge behavior, batch capping, and the maxDuration invariant versus the stale-claim threshold
  • Ensure failure logs include only request IDs (no user IDs) and that responses never contain PII
  • Add Playwright e2e tests that hit /api/cron/gdpr over HTTP to verify 401 behavior for missing/wrong tokens, constant 401 on wrong-length tokens, and indistinguishable error bodies between misconfig and wrong secret
  • Update CLAUDE.md to bump the documented Playwright e2e public test floor to reflect the new specs
src/app/api/cron/gdpr/__tests__/route.test.ts
e2e/cron-gdpr-auth.spec.ts
CLAUDE.md
Wire up and expose a service-role caller for the 12‑month audit-log purge RPC used by the cron.
  • Create a retention helper that calls purge_audit_log_older_than_12_months via a service-role Supabase client, interpreting its return value as deleted-row count and translating errors into thrown exceptions
  • Include this helper in the cron route and propagate its result into the response as a purged count
src/lib/gdpr/retention.ts
src/app/api/cron/gdpr/route.ts
Make CRON_SECRET a typed, optional server env var whose absence is handled at runtime with a 401 plus loud logging rather than failing schema validation or CI.
  • Add CRON_SECRET as an optional string of min length 32 to the server env Zod schema with documentation explaining why it is optional even in production
  • Rely on runtime checks in the cron route to emit a 401 and log an error when CRON_SECRET is missing, instead of failing builds or workflows
src/lib/env.ts
Shorten the GDPR account deletion grace period from 30 to 14 days and align tests, architecture docs, README, security/docs, and UI copy accordingly, including correcting an overstatement about what deletion removes.
  • Change GRACE_PERIOD_DAYS from 30 to 14 in the deletion orchestration wrapper and update its legal rationale in comments
  • Adjust deletion scheduling tests to assert a 14‑day window instead of 30 days
  • Update architecture and product-quality docs, README, SECURITY, and action comments to describe a 14‑day grace period and daily cron schedule
  • Refresh llms-full.txt and i18n message files so user-facing strings and public docs match the new 14‑day promise and accurately describe which data is deleted vs retained
src/lib/gdpr/deletion.ts
src/lib/gdpr/__tests__/deletion.test.ts
docs/ARCHITECTURE.md
docs/ankora-product-quality-bar-v1.md
README.md
SECURITY.md
src/lib/actions/settings.ts
messages/de-DE.json
messages/en.json
messages/es-ES.json
messages/fr-BE.json
messages/nl-BE.json
public/llms-full.txt
Update documentation formatting, tables, and roadmap to improve consistency and reflect the new GDPR behavior and test baselines.
  • Normalize table column alignment and spacing in product quality bar and README sections for vocabulary, dashboard zones, agents, and roadmap milestones
  • Document the new cron schedule and deletion behavior in the product-quality and roadmap docs and ensure references to deletion/export agents are current
  • Leave a placeholder in .env.example/vercel.json for cron-related config changes if any were introduced in PR-A and used here
docs/ankora-product-quality-bar-v1.md
README.md
CLAUDE.md
docs/ARCHITECTURE.md
vercel.json
.env.example

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

security-auditor a rendu BLOCK sur PR-B. Deux items, dont un que j'avais
introduit moi-même.

HIGH-2 — `.env.example` disait `openssl rand -base64 32`. Cette commande émet un
SAUT DE LIGNE. `z.string().min(32)` acceptait "secret\n", la spec fetch rogne la
valeur d'un en-tête reçu, donc `expected !== provided` POUR TOUJOURS : un 401 par
nuit, aucune alerte, le droit à l'effacement inexécuté indéfiniment. Le défaut
exact que cette route existe pour supprimer, réintroduit par un caractère blanc.
Corrigé aux deux bouts : `.trim().min(32).regex(/^\S+$/)` fait désormais échouer
le BUILD au lieu d'échouer à 03:00, et `.env.example` documente `| tr -d '\n'`
avec la raison.

MEDIUM-6 — la route promet « request_id, JAMAIS user_id », et le test le prouve
pour les liaisons structurées. Mais `error.message` vient de GoTrue ou de
PostgREST : il est écrit par quelqu'un d'autre et repris tel quel. La redaction
de pino travaille PAR CHEMIN — elle ne verra jamais un identifiant enfoui dans
une chaîne libre. La promesse était vraie sur le chemin que le test regarde, et
fausse sur celui qu'il ne regardait pas. `safeErrorMessage()` : 200 caractères,
UUID masqués.

MEDIUM-4 — le SQL borne à `least(coalesce(batch_size,1),100)`. Porter BATCH_SIZE
à 150 aurait fait rendre 100 au SQL, `claimed.length >= 150` jamais vrai, et
l'alarme `capped` aurait disparu SANS BRUIT. Une alarme qui ressemble à un
garde-fou est pire que pas d'alarme. Test d'une ligne.

MEDIUM-5 — le log.error du secret manquant partait à CHAQUE requête anonyme sur
un endpoint public non compté : un scanner remplissait le journal Vercel
gratuitement. Le commentaire affirmait que le 401 bornait le coût au CPU — vrai
seulement s'il borne aussi l'ingestion de logs. Une ligne par démarrage à froid,
et le test le vérifie maintenant explicitement.

LOW-7 — le garde « Supabase local » du spec destructeur testait la chaîne brute,
donc laissait passer la partie userinfo : `http://127.0.0.1:5442@<projet>.supabase.co`
était considérée locale. C'est le garde qui sépare un spec appelant un
claimPendingDeletionsWith NON SCOPÉ d'une base de production. Résolu par
`new URL().hostname`, vérifié contre le contournement exact mesuré par l'audit.

HIGH-1 NE SE CORRIGE PAS PAR DU CODE et bloque le premier run :
claim_pending_deletions ne filtre que sur l'échéance, donc toute demande
formulée sous la promesse « 30 jours » et échue pendant qu'AUCUN exécuteur ne
tournait (avril -> juillet) sera détruite à 03:00 le lendemain de la pose du
secret. Une PR dont la thèse est « mesurer avant d'affirmer » s'apprêtait à
détruire un nombre INCONNU de comptes tiers. Lecture n° 5 documentée dans le
rapport, à faire AVANT de poser CRON_SECRET dans Vercel.

HIGH-3 inscrit au DoD : log.error écrit sur stdout Vercel, sans drain ni alerte.
La vérification d'armement prouve le jour J, pas le jour J+40. Cette PR répare
une panne muette en installant un mécanisme qui peut lui aussi s'arrêter en
silence — dit dans le rapport, avec le détecteur SQL proposé.

18 tests sur la route, typecheck vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… de 227 à 224

silent-failure-auditor : SILENT_FAILURE_CONFIRMED, et il ne parle pas au
conditionnel.

MESURE DÉCISIVE, à porter à @Thierry avant tout :

  vercel env ls production  ->  pas de CRON_SECRET
  vercel crons ls           ->  /api/cron/gdpr  0 3 * * *  not deployed

Mergée et déployée telle quelle, cette PR arme un cron qui tire à 03:00, prend
un 401 et n'efface RIEN — pendant que la politique et les CGU annoncent
désormais « suppression effective 14 jours après la demande ».

Corollaire plus vicieux que l'absence : les variables Vercel sont figées dans un
déploiement. Poser le secret APRÈS le déploiement, ou le faire tourner sans
redéployer, donne un 401 quotidien SANS AUCUN LOG — `expected` est alors défini,
juste faux. Ordre obligatoire : poser -> redéployer -> vérifier.

F6, LE PLUS DUR POUR MOI. CRON_SECRET n'est défini dans aucun bloc `env` de
ci.yml ni dans .env.local, donc mes 4 specs x 3 projets sortaient par
`if (!expected)` et n'atteignaient JAMAIS secretMatches. Remplacer le corps de
secretMatches par `return true` les laissait toutes vertes. Et le cas « ne dit
jamais laquelle des deux refus » : en CI les deux refus sont littéralement la
même branche, l'assertion ne pouvait pas échouer.

J'avais donc relevé un plancher de 215 à 227 sur douze cas affirmant une seule
chose : « une route non configurée refuse ». Exactement le défaut que je
reprochais ailleurs dans cette même session.

Cas tautologique RETIRÉ, commentaires réécrits pour dire ce que les cas prouvent
ET ce qu'ils ne prouvent pas, plancher redescendu à +9. Un plancher bâti sur un
cas qui ne peut pas échouer est pire qu'un plancher plus bas : il inspire
confiance sans la mériter. Poser CRON_SECRET dans l'env du job e2e (un faux de
32 caractères) rendrait ces cas réels — mais éditer .github/workflows/ est banni
en PR feature, donc PR dédiée, avec le cas qui manque partout : un 200 sur HTTP
avec le bon secret.

F9 — mon commentaire se trompait D'UN FACTEUR 24. Il disait qu'une ligne bloquée
est « re-queued an hour later ». L'heure est un ÂGE MINIMUM, pas un horaire : la
remise en file a lieu au prochain appel, et l'unique appelant tourne une fois
par jour.

F2 — `purged: 0` est AUSSI la réponse saine, et le restera jusqu'à ~avril 2027 :
audit_log naît le 16 avril 2026, rien ne peut y avoir 12 mois avant. Une purge
cassée et une purge sans travail s'écrivaient identiquement pendant neuf mois.
La réponse porte maintenant `purgeOk`, et `purged: null` en cas d'échec.

NOMMÉ, PAS CONSTRUIT — F3, le plus grave. `order by scheduled_for` réclame les
plus anciennes d'abord et il n'existe AUCUNE colonne `attempts` : 25 lignes
empoisonnées suffisent à affamer la file POUR TOUJOURS. Pendant ce temps l'écran
affiche « La suppression a commencé, elle ne peut plus être annulée » et retire
le bouton d'annulation. La personne est enfermée entre les deux issues,
définitivement. Correctif = migration = élargissement de scope banni sans
nouveau plan. Ouvert comme suite immédiate.

17 tests sur la route, typecheck vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#285

Un piège qui ne vit que dans une conversation est un piège armé. Poser
CRON_SECRET est une action MANUELLE, faisable n'importe quel jour,
éventuellement dans trois semaines, éventuellement par une session qui n'aura lu
ni cette PR ni ce rapport. D'où une issue dédiée qui porte l'interdit —
même règle que #278.

Et le préalable que @Thierry a relevé et que je n'avais pas vu : la reprise fait
`set status='pending', claimed_at = null`. Elle EFFACE LA SEULE TRACE qu'une
tentative a eu lieu. Même avant d'ajouter un compteur, on ne peut déjà plus
distinguer une ligne jamais tentée d'une ligne tentée trois cents fois. J'avais
lu ce `claimed_at = null` comme une remise à zéro propre ; c'est aussi une
destruction de preuve. Quelle que soit la conception retenue, elle doit cesser
d'effacer.

Le rapport porte maintenant en tête l'ordre verrouillé : merge (sûr, rien n'est
armé) -> amendement ADR-024 + correctif #285 -> les deux lectures production
-> alors seulement poser le secret -> REDÉPLOYER -> vercel crons ls -> run
manuel file vide. La dernière étape est indivisible.

Correction d'une imprécision de mon rapport précédent : j'annonçais « deux
lectures production » en n'en donnant qu'une. La seconde n'est pas nouvelle,
c'est la n° 4 (privilèges des 9 fonctions sur l'hébergé), transmise le 27
juillet et non revenue. Elle est due, pas neuve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@thierryvm
thierryvm merged commit b74ad15 into main Jul 27, 2026
10 checks passed
@thierryvm
thierryvm deleted the feat/3b-b-cron-arming branch July 27, 2026 20:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status:review-needed Ready for review type:feat New user-facing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant