06.01 — Stack Vercel Edge

06.01 — Stack Vercel Edge#

package.json#

{
  "name": "tests-workers",
  "version": "1.0.0",
  "scripts": {
    "vercel-build": "echo 'Build skipped'"
  },
  "packageManager": "pnpm@11.2.2",
  "dependencies": {
    "@upstash/redis": "^1.36.2",
    "otplib": "^13.2.1"
  },
  "devDependencies": {
    "@types/node": "^25.2.0",
    "next": "^16.1.6",
    "@vercel/node": "^5.5.28",
    "typescript": "^5.9.3"
  },
  "license": "ISC"
}
LibRôle
@upstash/redis (runtime)Client Redis REST (pas TCP). Compatible Edge.
otplib (runtime)Génération TOTP RFC 6238.
next (dev seulement)Types uniquement (NextRequest). Pas d’app Next.js.
@vercel/node (dev seulement)Types Vercel.
@types/node (dev seulement)Types Node 22+.

Note : aucune dépendance runtime au-delà de @upstash/redis + otplib.
Le bundle est microscopique → cold start < 100ms.

06.02 — GET /api/otp

06.02 — GET /api/otp#

Fichier source : api/otp.ts

Code#

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

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

const SECRET_QUERY_PARAM_KEY = "secret";
const OTP_SECRET_MIN_LENGTH = 32;
const OTP_SECRET_MAX_LENGTH = 64;
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const OTP_TTL = 60 * 5; // 5 minutes
const OTP_HISTORY_TTL = OTP_TTL + 60; // 6 minutes
const MAX_FREE_PAYLOAD_SIZE = 4096;

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

function generateRandomBase32Secret(length: number = 32): string {
  let secret = "";
  const randomValues = new Uint8Array(length);
  crypto.getRandomValues(randomValues);
  for (let i = 0; i < length; i++) {
    secret += BASE32_ALPHABET[randomValues[i] % 32];
  }
  return secret;
}

function ensureSecretLength(secret: string): string {
  const cleaned = secret.toUpperCase().replace(/\s/g, "");
  if (cleaned.length > OTP_SECRET_MAX_LENGTH) {
    const validLength = Math.floor(OTP_SECRET_MAX_LENGTH / 8) * 8;
    return cleaned.substring(0, validLength);
  }
  if (cleaned.length >= OTP_SECRET_MIN_LENGTH) {
    const validLength = Math.floor(cleaned.length / 8) * 8;
    return cleaned.substring(0, validLength);
  }
  const repetitions = Math.ceil(OTP_SECRET_MIN_LENGTH / cleaned.length);
  const repeated = cleaned.repeat(repetitions);
  const validLength = Math.floor(repeated.length / 8) * 8;
  return repeated.substring(0, Math.max(validLength, OTP_SECRET_MIN_LENGTH));
}

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

  const { searchParams } = new URL(request.url);
  const freePayload: Record<string, string> = {};
  let payloadSize = 0;

  searchParams.forEach((value, key) => {
    if (key === SECRET_QUERY_PARAM_KEY) return;
    const entrySize = new TextEncoder().encode(key + value).length;
    if (payloadSize + entrySize > MAX_FREE_PAYLOAD_SIZE) {
      payloadSize += entrySize;
      return;
    }
    freePayload[key] = value;
    payloadSize += entrySize;
  });

  if (payloadSize > MAX_FREE_PAYLOAD_SIZE) {
    return new Response(
      JSON.stringify({
        error: "Bad Request",
        message: `Free payload exceeds maximum size of ${MAX_FREE_PAYLOAD_SIZE} bytes (got ${payloadSize} bytes)`,
      }),
      {
        status: 400,
        headers: { "Content-Type": "application/json", ...corsHeaders },
      },
    );
  }

  const rawSecret =
    searchParams.get(SECRET_QUERY_PARAM_KEY) ?? generateRandomBase32Secret();
  const secret = ensureSecretLength(rawSecret);
  const otpCode = await generate({ secret });
  const now = Date.now();
  const expiresAtMs = now + OTP_TTL * 1000;
  const createdAtTimestampLackingMsPrecision = new Date(
    now - (now % 1000),
  ).toISOString();

  const event = {
    ...freePayload,
    otpCode,
    secret,
    createdAtTimestampLackingMsPrecision,
    expiresAt: new Date(expiresAtMs).toISOString(),
  };

  const key = `otp:${now}:${crypto.randomUUID()}`;
  await redis.set(key, JSON.stringify(event), { ex: OTP_HISTORY_TTL });

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

Flot#

1. Request arrive
   ↓
2. Si OPTIONS → 200 + CORS headers (preflight)
   ↓
3. isAuthorized(request) ? Non → 401
   ↓
4. Parse searchParams ; tout sauf "secret" est mis dans freePayload
   ↓
5. payloadSize > 4 KB ? → 400
   ↓
6. secret = ?secret=<X> (si fourni) sinon random Base32 32-chars
   ↓
7. ensureSecretLength : assure 32..64 chars, multiple de 8
   ↓
8. otpCode = generate({ secret })   ← otplib TOTP RFC 6238
   ↓
9. createdAt = floor(now / 1000) * 1000  ← amputation des ms
   ↓
10. event = { ...freePayload, otpCode, secret, createdAt, expiresAt }
    ↓
11. key = `otp:<now>:<uuid>`
    ↓
12. redis.set(key, JSON.stringify(event), { ex: 360 })  ← TTL 6 min
    ↓
13. Response = JSON.stringify(event), 200

CORS headers#

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
  "Access-Control-Allow-Headers": "x-api-key, Content-Type",
} as const;
  • * origin : permet à l’Igoristan (depuis https://mojo-molotov.github.io) d’appeler le service.
  • GET, OPTIONS : seuls verbes supportés.
  • Custom header x-api-key autorisé (sinon le browser bloquerait).

as const rend l’objet readonly côté TypeScript.

06.03 — GET /api/otp-history

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 ?

06.04 — GET /api/corsicadex?id=N

06.04 — GET /api/corsicadex?id=N#

Fichier source : api/corsicadex.ts

Code#

import type { NextRequest } from "next/server";
import isAuthorized from "../lib/isAuthorized";
import { corsicaDexData } from "../consts/corsicadexData";

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

  const url = new URL(request.url);
  const id = url.searchParams.get("id");

  if (!id) {
    return new Response(JSON.stringify({ error: "Missing id parameter" }), {
      status: 400,
      headers: { "Content-Type": "application/json", ...corsHeaders },
    });
  }

  const corsicamonId = parseInt(id, 10);

  if (isNaN(corsicamonId)) {
    return new Response(JSON.stringify({ error: "Invalid ID" }), {
      status: 400,
      headers: { "Content-Type": "application/json", ...corsHeaders },
    });
  }

  const corsicamon = corsicaDexData.find((p) => p.id === corsicamonId);

  if (!corsicamon) {
    return new Response(JSON.stringify({ error: "Corsicamon not found" }), {
      status: 404,
      headers: { "Content-Type": "application/json", ...corsHeaders },
    });
  }

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

Mécanique#

Lookup statique dans un array TypeScript.

06.05 — isAuthorized.ts

06.05 — isAuthorized.ts#

Fichier source : lib/isAuthorized.ts

Vérifie qu’une requête contient le bon x-api-key.

Code#

import type { NextRequest } from "next/server";

const API_SECRET = process.env.API_SECRET;

function isAuthorized(request: NextRequest): boolean {
  if (typeof API_SECRET !== "string") {
    console.error("[CONFIG ERROR] API_SECRET is not defined");
    return false;
  }

  const { searchParams } = new URL(request.url);
  const apiKey = searchParams.get("apiKey") ?? request.headers.get("x-api-key");
  return apiKey === API_SECRET;
}

export default isAuthorized;

Approche#

1. API_SECRET#

const API_SECRET = process.env.API_SECRET;

Lu une seule fois, au démarrage du worker Edge (cold start).
Stocké en tant que variable de module.

06.06 — lib/redis.ts + Upstash

06.06 — lib/redis.ts + Upstash#

Fichier source : lib/redis.ts

Code#

import { Redis } from "@upstash/redis";

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL,
  token: process.env.UPSTASH_REDIS_REST_TOKEN,
});

export default redis;

Pourquoi @upstash/redis plutôt que ioredis#

Critèreioredis@upstash/redis
ProtocoleTCP / RESPHTTP / REST
Edge runtime❌ Non✅ Oui
ConnexionPersistantePer-request
Latence (warm)< 1ms50-100ms
Latence (cold)200ms+50-100ms
CoûtVariablePay-per-request
SetupSelf-hosted RedisUpstash dashboard (managed)

L’Edge runtime n’accepte pas de connexions TCP persistantes (chaque worker est éphémère).
Donc : Upstash REST.

06.07 — Flux de coordination OTP + l'anti-précision volontaire

06.07 — Flux de coordination OTP + l’imprécision volontaire#

La raison d’être du backend : permettre à N workers parallèles d’Ocarina de récupérer le bon OTP pour leur user, même quand plusieurs OTP sont générés.

Flux#

   ┌───────────────────────────────────────────────────────────────────┐
   │               Worker (parmi --workers 3 d'Ocarina)                │
   └───────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Selenium ouvre la page de connexion au Dashboard                  │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Acquisition du lock distribué Redis (OTP_SEND_LOCK_KEY)           │
   │   → seul ce worker peut cliquer "Send OTP" pendant ACQ            │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ min_utc_date = datetime.now(UTC)                                  │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Cache L1 : enregistre min_utc_date + username dans le cache       │
   │   (clés réservées par reserve_free_cache_key)                     │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Selenium tape username + password + cocher OTP + click "REQ OTP"  │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ IGORISTAN UI : fetch /api/otp?_user=<username>                    │
   │   (x-api-key tapé par Selenium dans l'UI)                         │
   └─────────────────────────────────┬─────────────────────────────────┘
                                     ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ TESTS-WORKERS /api/otp :                                          │
   │   generate(secret) → otpCode                                      │
   │   createdAt = floor(now/1000)*1000  ← amputation ms               │
   │   event = { _user, otpCode, createdAt, expiresAt, ... }           │
   │   redis.set("otp:<now>:<uuid>", JSON, EX 360)                     │
   │   return event                                                    │
   └──────────────────────────────────┬────────────────────────────────┘
                                      ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ IGORISTAN UI : affiche écran OTP                                  │
   └──────────────────────────────────┬────────────────────────────────┘
                                      ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Release du lock Redis OTP_SEND_LOCK_KEY                           │
   └──────────────────────────────────┬────────────────────────────────┘
                                      ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Selenium : retrieve_dashboard_otp_code(min_utc_date, _user)       │
   │   ↓                                                               │
   │   GET /api/otp-history  (x-api-key = IGOR_API_KEY)                │
   │   ↓                                                               │
   │   TESTS-WORKERS : SCAN otp:* + MGET → all events                  │
   │   ↓                                                               │
   │   filtre côté client par _user                                    │
   │   filtre createdAt >= min_utc_date - 1s                           │
   │   tri ASC sur createdAt                                           │
   │   return first.otpCode                                            │
   └──────────────────────────────────┬────────────────────────────────┘
                                      ▼
   ┌───────────────────────────────────────────────────────────────────┐
   │ Selenium tape l'OTP dans l'UI Igoristan                           │
   │   → AUTHENTICATED_WITH_MFA                                        │
   └───────────────────────────────────────────────────────────────────┘

Races conditions#

  1. Worker A : min_utc_date_A = 13:27:53.123, génère OTP_A à 13:27:53.250.
  2. Worker B : min_utc_date_B = 13:27:53.130, génère OTP_B à 13:27:53.470.

Sans coordination, A pourrait récupérer OTPB (au lieu de OTP_A) parce que les timestamps sont _très proches (et tronqués à la seconde près).