Tokens Anonymous

Survey privacy

The “For real, how about you?” survey asks a health question: ADHD. Here is exactly what happens when you answer. No empty promises: the code excerpts below are imported from the code running in production.

In one sentence

Your answer goes through Cloudflare, then our server, which immediately turns it into +1 in a counter. Nothing we store contains an identifier.

What leaves your browser

One single POST /api/answer request, with this JSON (for example):

request body
{
  "version": 2,
  "consent": true,
  "lang": "en",
  "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"
  }
}

Plus an X-Turnstile-Token header: a single-use anti-bot token. No cookies. The name written on the badge is not sent, and neither is your exact test score: only its band.

What the server does with it

It checks the size, the values and your consent, verifies the token with Turnstile without sending it your IP, then adds +1 to one row. That’s all:

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' });
}

What we store

These are the tables of the current questionnaire (the first version’s counters table is shorter, with no extra column). The common part is one counter per combination of answers. Fatigue answers, however, are never stored together: each submission adds +1 to eight pairs, for example “4 tasks or more × fatigue going up” or “ADHD × brain fog”.

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;

What we don’t do

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

The limits, honestly

Legal basis and rights

Publication

Nothing is published yet. Once we have enough answers, we’ll only publish groups of at least 30 answers, as rounded percentages.

The code

The site’s repository will be made public. Meanwhile, the excerpts on this page are imported at build time from the deployed code.

Back to the meeting