Tokens Anonymes

Vie privée du sondage

Le sondage « Pour de vrai, toi ? » pose une question de santé : le TDAH. Voici exactement ce qui se passe quand tu réponds. Pas de promesse en l’air : les extraits de code ci-dessous sont importés du code qui tourne en production.

En une phrase

Ta réponse traverse Cloudflare puis notre serveur, qui la transforme aussitôt en +1 dans un compteur. Rien de ce qu’on stocke ne contient d’identifiant.

Ce qui part de ton navigateur

Une seule requête POST /api/answer, avec ce JSON (par exemple) :

corps de la requête
{
  "version": 2,
  "consent": true,
  "lang": "fr",
  "problem": "moderately",
  "adhd": "suspected",
  "tech": true,
  "usage": "build",
  "confide": "days_0",
  "scoreBand": "mid",
  "workAi": true,
  "fatigue": {
    "parallel": "two_three",
    "fatigueNow": "high",
    "fatigueChange": "higher",
    "fog": "moderately"
  }
}

S’y ajoute un en-tête X-Turnstile-Token : un jeton anti-robots, à usage unique. Aucun cookie. Le prénom écrit sur le badge ne part pas, et ton score exact au test non plus : seulement sa tranche.

Ce que le serveur en fait

Il vérifie la taille, les valeurs et ton consentement, contrôle le jeton auprès de Turnstile sans lui transmettre ton IP, puis ajoute +1 à une ligne. C’est tout :

src/survey.ts
export async function handleAnswer(request: Request, env: SurveyEnv): Promise<Response> {
  if (request.method !== 'POST') return empty(405, { Allow: 'POST' });
  if (!isAllowedOrigin(request.headers.get('Origin'))) return empty(403);
  if (!request.headers.get('Content-Type')?.startsWith('application/json')) return empty(415);

  // 1. Corps borné à 1 Ko, puis validation stricte : clés et valeurs attendues seulement.
  const body = await readBounded(request, MAX_BODY_BYTES);
  if (body === null) return empty(413);
  let answer: Answer | null = null;
  try {
    answer = parseAnswer(JSON.parse(body));
  } catch {
    answer = null;
  }
  if (!answer) return empty(400);

  // 2. Anti-robots (Turnstile), puis limite globale de débit, sans IP.
  if (!(await isHuman(request.headers.get('X-Turnstile-Token'), env.TURNSTILE_SECRET))) return empty(403);
  const { success } = await env.ANSWER_LIMITER.limit({ key: 'answer' });
  if (!success) return empty(429);

  // 3. Une réponse = +1 sur une ligne du tronc et, si l'IA a servi au travail, +1 sur
  //    8 paires du bloc fatigue. Ni IP, ni identifiant, ni heure : seulement le mois.
  //    Le batch D1 est atomique : tout ou rien.
  const month = new Date().toISOString().slice(0, 7);
  const statements = [
    env.DB.prepare(
      `INSERT INTO answers_v2 (version, month, lang, problem, adhd, tech, usage, confide, work_ai, score_band, n)
       VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1)
       ON CONFLICT (version, month, lang, problem, adhd, tech, usage, confide, work_ai, score_band)
       DO UPDATE SET n = n + 1`,
    ).bind(
      answer.version,
      month,
      answer.lang,
      answer.problem,
      answer.adhd,
      answer.tech ? 1 : 0,
      answer.usage,
      answer.confide,
      answer.workAi ? 1 : 0,
      answer.scoreBand,
    ),
  ];
  if (answer.fatigue) {
    const values: Record<string, string> = { ...answer.fatigue, adhd: answer.adhd, usage: answer.usage, problem: answer.problem };
    for (const [a, b] of FATIGUE_PAIRS) {
      statements.push(
        env.DB.prepare(
          `INSERT INTO fatigue_pairs (version, month, a_key, a_value, b_key, b_value, n)
           VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)
           ON CONFLICT (version, month, a_key, a_value, b_key, b_value) DO UPDATE SET n = n + 1`,
        ).bind(answer.version, month, a, values[a]!, b, values[b]!),
      );
    }
  }
  try {
    await env.DB.batch(statements);
  } catch {
    // Pas de journalisation : le message d'erreur pourrait contenir les valeurs liées.
    return empty(503);
  }

  return empty(204, { 'Cache-Control': 'no-store' });
}

Ce qu’on stocke

Voici les tables du questionnaire actuel (la table counters de la première version, plus courte, n’a pas de colonne en plus). Le tronc commun est un compteur par combinaison de réponses. Les réponses sur la fatigue, elles, ne sont jamais stockées ensemble : chaque envoi ajoute +1 à huit paires, par exemple « 4 tâches ou plus × fatigue en hausse » ou « TDAH × brouillard ».

migrations/0002_v2.sql
-- Tronc commun : un compteur par combinaison de réponses.
CREATE TABLE answers_v2 (
  version    INTEGER NOT NULL, -- 2
  month      TEXT    NOT NULL, -- 'YYYY-MM', calculé par le serveur
  lang       TEXT    NOT NULL, -- 'fr' | 'en'
  problem    TEXT    NOT NULL, -- 7 derniers jours : not_at_all | a_little | moderately | a_lot | extremely
  adhd       TEXT    NOT NULL, -- diagnosed | suspected | no | undisclosed
  tech       INTEGER NOT NULL, -- 0 | 1
  usage      TEXT    NOT NULL, -- usage le plus long : build | research | support | other | mixed
  confide    TEXT    NOT NULL, -- jours de confidences sur 7 : days_0 | days_1_2 | days_3_7 | undisclosed
  work_ai    INTEGER NOT NULL, -- IA utilisée pour travailler ces 7 derniers jours : 0 | 1
  score_band TEXT    NOT NULL, -- score au test parodique : low | mid | high
  n          INTEGER NOT NULL DEFAULT 0,
  PRIMARY KEY (version, month, lang, problem, adhd, tech, usage, confide, work_ai, score_band)
) WITHOUT ROWID;

-- Bloc fatigue : seulement des paires de réponses, jamais la combinaison complète.
CREATE TABLE fatigue_pairs (
  version INTEGER NOT NULL, -- 2
  month   TEXT    NOT NULL, -- 'YYYY-MM'
  a_key   TEXT    NOT NULL, -- ex. 'parallel'
  a_value TEXT    NOT NULL, -- ex. 'four_plus'
  b_key   TEXT    NOT NULL, -- ex. 'fatigueChange'
  b_value TEXT    NOT NULL, -- ex. 'higher'
  n       INTEGER NOT NULL DEFAULT 0,
  PRIMARY KEY (version, month, a_key, a_value, b_key, b_value)
) WITHOUT ROWID;

Ce qu’on ne fait pas

wrangler.jsonc
  "observability": { "enabled": false }

Les limites, honnêtement

Base légale et droits

Publication

Rien n’est publié pour l’instant. Quand on aura assez de réponses, on ne publiera que des groupes d’au moins 30 réponses, en pourcentages arrondis.

Le code

Le dépôt du site sera rendu public. En attendant, les extraits de cette page sont importés au moment du build depuis le code déployé.

Revenir à la réunion