API

Routeur HTTP unique de la plateforme, définition de routes, niveaux d'authentification, permissions et utilitaires de réponse.

core/api est un routeur générique. Il ne connaît aucune feature : les features et les modules y enregistrent leurs routes. Ajouter une route ne demande jamais de modifier le routeur.

Règle absolue : toutes les routes HTTP de la plateforme passent par ce module, sous le préfixe /lrvo/api. Aucune autre route API.

Import

import {
  routeRequest, requireAuth, requireAdmin,
  configureRouter, registerApiRoutes, getFeatureRoutes,
  apiSuccess, apiError, getStatusCode,
  defineApiRoutes,
  buildFileResponse,
} from '@lrvo/core/api';

// Intégration Next.js App Router
import { GET, POST, PUT, PATCH, DELETE } from '@lrvo/core/api/handler';

Module serveur. Ne jamais l'importer depuis un fichier 'use client'.

Fichiers

FichierRôle
router.jsOrchestration : rate limit, CSRF, auth, dispatch.
runtime.jsÉtat global : résolveur de session et registre des routes de features.
route-handler.jsIntégration Next.js App Router.
define.jsdefineApiRoutes(), validateur de définitions.
respond.jsapiSuccess() / apiError() / getStatusCode().
core-routes.jsIndex des routes internes (health, storage).
file-response.jsRéponse streaming pour GET /lrvo/api/storage/**.
health.jsGET /lrvo/api/health, seule route exemptée de rate limit.

Les routes vivent avec leur feature, pas dans le routeur : src/features/auth/api.js porte /lrvo/api/users/*, src/core/storage/api.js porte /lrvo/api/storage/**.

Cycle de vie d'une requête

route-handler.js (GET / POST / PUT / DELETE / PATCH)
  └─→ routeRequest(request, path)
        ├─ matchRoute(pattern, path)   — exact, :param, /**
        │    sur toutes les routes (core d'abord, modules ensuite)
        ├─ Résolution de session (uniquement si un cookie de session est présent)
        ├─ Rate limit par identité : compte si connecté, IP sinon (sauf GET /health)
        ├─ CSRF (méthodes mutantes uniquement)
        ├─ Auth depuis la définition de route, sur la session déjà résolue
        │    'admin'  → session + admin.access ; si `permission` défini → hasPermission() sinon 403
        │    'user'   → session requise
        │    'public' → aucun contrôle
        └─ handler(request, params, context)

API

defineApiRoutes(routes)

Valide un tableau de définitions de routes au moment de l'import, donc au démarrage du serveur. Une erreur de configuration lève immédiatement une TypeError avec un message précis, plutôt qu'un 404 silencieux en production.

import { defineApiRoutes, apiSuccess, apiError } from '@lrvo/core/api';

export const routes = defineApiRoutes([
  { path: '/settings',     method: 'GET', handler: handleGetSettings,    auth: 'user'  },
  { path: '/settings',     method: 'PUT', handler: handleUpdateSettings, auth: 'admin', permission: 'settings.manage' },
  { path: '/posts/:slug',  method: 'GET', handler: handleGetPost,        auth: 'public' },
]);

Champs requis :

ChampTypeValeurs
pathstringCommence par /. Supporte :param et /** en fin de motif.
methodstringGET | POST | PUT | PATCH | DELETE
handlerFunction(request, params, context) => Promise<Object>
authstring'public' | 'user' | 'admin'

Champs optionnels :

ChampTypeEffet
skipRateLimitbooleanExempte la route du rate limit. Réservé aux health checks et aux endpoints qui reçoivent des rafales légitimes.
rateLimitstringNom d'un préréglage de RATE_LIMITS à employer à la place du budget api. La clé du compteur est préréglage:identité, donc la route obtient son propre budget. À poser sur une route dont l'usage normal est une longue série de requêtes, comme la réception d'une tranche d'envoi par requête, qui épuiserait sinon le budget partagé de cette adresse. Un nom absent de RATE_LIMITS fait échouer le démarrage, parce qu'il désactiverait silencieusement le rate limit sur cette route.
skipCsrfbooleanExempte la route de la vérification d'origine. Réservé aux appels machine à machine, qui n'envoient ni Origin ni Referer et prouvent leur identité autrement. Le handler doit vérifier cette preuve avant d'agir : sans elle, la route est une écriture non authentifiée. Seul le webhook Stripe l'utilise dans le core.
permissionstringSur une route auth: 'admin', exige en plus cette clé granulaire (ex. 'users.manage'). Retourne 403 sinon. Voir Utilisateurs.

Niveaux d'authentification

NiveauQui peut appelercontext.session
'public'Tout le monde, sans cookieundefined
'user'Utilisateur avec une session valide{ user: { id, email, name, role, … } }
'admin'Utilisateur avec la permission admin.access{ user: { … } }

L'authentification est toujours déclarée dans la définition de route, jamais vérifiée à la main dans un handler. Un handler qui ré-implémente le contrôle d'accès crée une seconde source de vérité qui finira par diverger.

Signature des handlers

async function handleSomething(request, params, context) {
  // request — objet Request de Next.js
  // params  — paramètres extraits du chemin : { id: '42' }
  //           routes wildcard (/storage/**) : params.wildcard
  // context — { session? } ; session présente dès que auth !== 'public'
}

Les handlers de modules écrits avec la signature (request, params) continuent de fonctionner : les arguments supplémentaires sont simplement ignorés.

registerApiRoutes(routes)

Ajoute des routes au registre runtime. C'est la fonction à appeler, aussi bien depuis le register() d'un module que depuis une feature du core.

registerFeatureRoutes en est un alias déprécié, conservé pour la compatibilité et encore utilisé par initializeLrvo(). Un nouveau code n'y fait pas appel.

// module externe — src/register-server.js
import { registerApiRoutes } from '@lrvo/core/api';
import { routes } from './api.server.js';

registerApiRoutes(routes);

core-routes.js n'est utilisé que pour les routes internes de l'infrastructure. Une feature ou un module ne le touche jamais.

apiSuccess(data) et apiError(code, message)

Tous les handlers retournent l'un des deux. L'intention reste explicite et le mapping vers un code HTTP est centralisé.

// Lecture
return apiSuccess({ user: { id, email, name } });

// Mutation, avec confirmation pour le frontend
return apiSuccess({ success: true, user: updatedUser, message: 'Profil mis à jour' });

// Erreurs
return apiError('Not Found',   'Utilisateur introuvable');
return apiError('Bad Request', 'Le nom est requis');
return apiError('Forbidden',   'Accès refusé');

Codes reconnus par getStatusCode() :

CodeStatut HTTP
'Bad Request'400
'Unauthorized'401
'Forbidden'403
'Admin access required'403
'Not Found'404
'Conflict'409
'Range Not Satisfiable'416
'Too Many Requests'429
'Internal Server Error'500

Tout autre code retombe sur 500. Un code inventé au moment d'écrire un handler produit donc une erreur serveur là où l'intention était un 4xx : n'utiliser que les codes de ce tableau, et en ajouter un ici et dans respond.js quand un cas manque.

GET /lrvo/api/health

Route publique de vivacité, exemptée de rate limit pour supporter une sonde fréquente.

{ "status": "ok", "timestamp": "2026-08-12T14:03:00.000Z" }

Elle ne retourne ni version ni temps de fonctionnement : ce sont des données d'empreinte qui aident à cibler une version vulnérable ou à repérer un redémarrage.

requireAuth() et requireAdmin()

Utilisées par le routeur pour appliquer auth. Un handler n'a pas à les appeler : la session est déjà dans context.session quand elle est requise.

buildFileResponse(file)

Construit une réponse HTTP streaming pour un fichier issu de core/storage. Utilisée par la route GET /lrvo/api/storage/**.

file porte body, et facultativement contentType, contentLength, lastModified, filename, disposition, status, contentRange et acceptRanges. Les images JPEG, PNG, GIF et WebP partent en Content-Disposition: inline ; tout le reste est forcé en téléchargement, avec le filename quand il est fourni. La réponse ajoute X-Content-Type-Options: nosniff et X-Frame-Options: DENY.

disposition: 'inline' renverse cette règle pour une réponse : le fichier est rendu par le navigateur au lieu d'être téléchargé, et l'en-tête de cadrage passe à SAMEORIGIN pour qu'un <iframe> du même site puisse l'afficher. Le filename reste émis, c'est lui que propose le bouton de téléchargement de la visionneuse.

acceptRanges: true annonce au client qu'il peut demander une plage d'octets. Un handler qui en sert une pose status: 206 et contentRange. Toute autre valeur de status retombe sur 200 : le statut d'une réponse fichier n'est pas un champ libre. Sans plages, Safari refuse de lire une vidéo et aucun navigateur ne sait s'y déplacer, donc une route qui sert du média a intérêt à les honorer. Une plage hors du fichier se refuse avec apiError('Range Not Satisfiable', …).

Le drapeau disposition est posé par le handler, jamais déduit du type de contenu. Il ne s'applique qu'à des octets produits par le serveur, un PDF rendu ou un export : un document affiché en ligne s'exécute dans l'origine du site qui le sert, donc un fichier téléversé ne doit jamais l'obtenir. La route storage ne le pose pas.

async function handleInvoicePdf(request, params) {
  const pdf = await renderInvoicePdf(invoice);
  return {
    success: true,
    file: {
      body: pdf.buffer,
      contentType: pdf.contentType,
      contentLength: pdf.buffer.length,
      filename: pdf.filename,
      disposition: 'inline',
    },
  };
}

configureRouter(options) et getSessionResolver()

Injectent le résolveur de session utilisé par requireAuth / requireAdmin. Câblé une fois par initializeLrvo(). Un projet n'y touche pas.

Notes

Le handler storage est déclaré auth: 'public' mais effectue sa propre validation d'accès à partir du chemin du fichier demandé. Les règles sont dans Stockage. C'est la seule route qui décide de son accès elle-même, parce que la décision dépend du chemin, pas de la route.

Les modules sont chargés hors condition react-server. Un fichier atteignable depuis le register() d'un module ne doit pas importer next/headers au top-level. Le routeur importe cookies en lazy à l'intérieur de ses handlers pour cette raison. Voir Frontières serveur/client.

Sur cette page