06.03 — GET /api/otp-history#

Fichier source : api/otp-history.ts

Code#

import type { NextRequest } from "next/server";
import redis from "../lib/redis";
import isAuthorized from "../lib/isAuthorized";

export const config = { runtime: "edge" };

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
  "Access-Control-Allow-Headers": "x-api-key, Content-Type",
} as const;

export default async function handler(request: NextRequest) {
  if (request.method === "OPTIONS") {
    return new Response(null, { status: 200, headers: corsHeaders });
  }

  if (!isAuthorized(request)) {
    return new Response(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
      headers: { "Content-Type": "application/json", ...corsHeaders },
    });
  }

  let cursor = "0";
  const allEvents = [];
  const BATCH_SIZE = 100;

  do {
    const [newCursor, keys] = await redis.scan(cursor, {
      match: "otp:*",
      count: BATCH_SIZE,
    });
    cursor = newCursor;

    if (keys.length > 0) {
      const values = await redis.mget(...keys);
      allEvents.push(...values.filter(Boolean));
    }
  } while (cursor !== "0");

  return new Response(JSON.stringify(allEvents), {
    headers: { "Content-Type": "application/json", ...corsHeaders },
  });
}

Mécanique de SCAN Redis#

let cursor = "0";
const allEvents = [];
const BATCH_SIZE = 100;

do {
  const [newCursor, keys] = await redis.scan(cursor, {
    match: "otp:*",
    count: BATCH_SIZE,
  });
  cursor = newCursor;

  if (keys.length > 0) {
    const values = await redis.mget(...keys);
    allEvents.push(...values.filter(Boolean));
  }
} while (cursor !== "0");
  1. cursor = "0" (initialisation).
  2. Boucle :
    • SCAN <cursor> MATCH otp:* COUNT 100 retourne [newCursor, keys].
    • Si keys non vide : MGET k1 k2 ... récupère les valeurs.
    • cursor = newCursor.
  3. On s’arrête quand cursor === "0" (cycle terminé).

Pourquoi SCAN plutôt que KEYS ?

KEYS otp:*SCAN 0 MATCH otp:*
Bloque le serveur RedisItératif, ne bloque pas
Renvoie tout d’un coupPages de N
Déconseillé en prodRecommandé

Pour 1000 clés otp:*, on fait ~10 round-trips.

Pourquoi MGET plutôt que GET individuel ?#

MGET k1 k2 k3 ... kN est un round-trip qui ramène N valeurs.
GET k1; GET k2; ...; GET kN serait N round-trips.

Sur un Upstash REST (HTTP), chaque round-trip coûte ~50ms.
Pour 100 clés : 100*50 = 5000ms.

.filter(Boolean)#

allEvents.push(...values.filter(Boolean));

MGET peut retourner null pour les clés qui ont expiré entre le SCAN et le MGET (race condition).
.filter(Boolean) élimine les nullundefined qui sont falsy en Javascript.

Résultat#

return new Response(JSON.stringify(allEvents), {
  headers: { "Content-Type": "application/json", ...corsHeaders },
});
[
  {
    "_user": "SacredFigatellu",
    "otpCode": "123456",
    "secret": "...",
    "createdAtTimestampLackingMsPrecision": "2024-05-17T13:27:53.000Z",
    "expiresAt": "2024-05-17T13:32:53.123Z"
  },
  {
    "_user": "Napoleon",
    "otpCode": "789012",
    ...
  },
  ...
]

Sécurité#

if (!isAuthorized(request)) {
  return new Response(JSON.stringify({ error: "Unauthorized" }),
    { status: 401, ... });
}

Sans x-api-key correct, 401.
Donc l’historique n’est lisible que par quelqu’un qui connaît API_SECRET.

Contrat avec Ocarina#

ocarina-example/api/get_otp_history.py :

def get_otp_history(*, igor_api_key: str, timeout: int) -> list[dict[str, Any]]:
    response = requests.get(
        "https://tests-workers.vercel.app/api/otp-history",
        headers={"x-api-key": igor_api_key},
        timeout=timeout,
    )
    response.raise_for_status()
    return response.json()

→ Un simple GET, header x-api-key, parse JSON.
Pas de pagination côté client (l’endpoint a déjà tout agrégé).

Coût#

Pour 100 events dans Redis :

  • 1 SCAN → 100 keys retournées (1 round-trip).
  • 1 MGET de 100 keys → 100 values (1 round-trip).
  • Total : 2 round-trips.

Pour 1000 events :

  • 10 SCAN.
  • 10 MGET.
  • Total : 20 round-trips.

À ~50ms par round-trip, c’est 1s pour 1000 events.

Sémantique « historique partagé »#

Tous les OTP émis par n’importe quel worker (invoqué par un humain ou par Selenium, pour n’importe quel user) finissent dans Redis.
Le filtre par _user se fait côté client, pas côté serveur.

  1. Simplicité côté serveur : pas de logique de filtre, juste un dump.
  2. Flexibilité : un client pourrait filtrer par _env, par _userId, par _anything. Le serveur ne sait pas ce qu’il y a dans le free payload.
  3. Volume gérable : 6 min × N OTP/sec = quelques centaines, pas des millions.

C’est une approche « Smart Client / Dumb Server » : la logique applicative est côté tests, le serveur se contente de dump.