Files
core/src/core/api/router.js
T
hykocx 7afcb2cb5a refactor(admin): split protect guards into dedicated export path
- remove `protectAdmin`/`isAdmin` re-exports from `features/admin/index.js` to avoid top-level `next/headers` import
- add `./features/admin/protect` export entry in `package.json`
- lazy-import `next/headers` in `router.js` `requireAuth` to defer resolution
- update `features/admin/README.md` to document new import paths
- translate `features/auth/index.js` comment to French for consistency
2026-04-25 13:01:06 -04:00

301 lines
9.9 KiB
JavaScript

/**
* API Router
*
* Orchestre rate limiting, CSRF, enforcement d'auth et dispatch vers les handlers.
* N'a aucune connaissance des features spécifiques — les routes s'auto-enregistrent.
*
* Initialisation requise :
* Appeler configureRouter({ resolveSession }) une fois au démarrage (dans initializeZen)
* avant toute requête. Le resolver est fourni par la feature auth, ce qui garde
* core/api/ libre de tout import feature.
*
* Cycle de vie d'une requête :
* route-handler.js → routeRequest()
* → rate limit (routes skipRateLimit exemptées)
* → validation CSRF (méthodes state-mutating uniquement)
* → matching sur toutes les routes (core en premier, puis features, puis modules)
* → enforcement auth depuis la définition de route
* → handler(request, params, context)
*/
import { getSessionCookieName } from '@zen/core/shared/config';
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '@zen/core/shared/rate-limit';
import { fail } from '@zen/core/shared/logger';
import { hasPermission, PERMISSIONS } from '@zen/core/users';
import { getCoreRoutes } from './core-routes.js';
import { getFeatureRoutes, getSessionResolver } from './runtime.js';
import { apiError } from './respond.js';
export { configureRouter, getSessionResolver, clearRouterConfig } from './runtime.js';
const COOKIE_NAME = getSessionCookieName();
// ---------------------------------------------------------------------------
// Auth helpers
// ---------------------------------------------------------------------------
/**
* Exige une session valide. Lève une erreur si aucun cookie valide n'est présent.
* @returns {Promise<Object>} session
*/
export async function requireAuth() {
const { cookies } = await import('next/headers');
const cookieStore = await cookies();
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
if (!sessionToken) {
throw new Error('Unauthorized');
}
const session = await getSessionResolver()(sessionToken);
if (!session || !session.user) {
throw new Error('Unauthorized');
}
return session;
}
/**
* Exige une session avec la permission admin.access.
* @returns {Promise<Object>} session
*/
export async function requireAdmin() {
const session = await requireAuth();
const allowed = await hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
if (!allowed) {
throw new Error('Admin access required');
}
return session;
}
// ---------------------------------------------------------------------------
// CSRF
// ---------------------------------------------------------------------------
/**
* Résout l'URL canonique de l'application depuis les variables d'environnement.
* Priorité : NEXT_PUBLIC_URL_DEV (développement) → NEXT_PUBLIC_URL (production).
*/
function resolveAppUrl() {
if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_URL_DEV) {
return process.env.NEXT_PUBLIC_URL_DEV;
}
return process.env.NEXT_PUBLIC_URL || null;
}
/**
* Vérifie que les requêtes state-mutating proviennent de l'origine attendue.
* GET, HEAD et OPTIONS sont exemptés (RFC 7231).
* @param {Request} request
* @returns {boolean}
*/
function passesCsrfCheck(request) {
const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS']);
if (safeMethods.has(request.method)) return true;
const appUrl = resolveAppUrl();
if (!appUrl) {
fail('CSRF: no app URL configured (NEXT_PUBLIC_URL_DEV / NEXT_PUBLIC_URL) — request denied');
return false;
}
let expectedOrigin;
try {
expectedOrigin = new URL(appUrl).origin;
} catch {
fail(`CSRF: configured app URL is not valid: ${appUrl}`);
return false;
}
const origin = request.headers.get('origin');
if (origin) {
return origin === expectedOrigin;
}
// Pas d'en-tête Origin : repli sur Referer (anciens navigateurs).
const referer = request.headers.get('referer');
if (referer) {
try {
return new URL(referer).origin === expectedOrigin;
} catch {
return false;
}
}
// Ni Origin ni Referer — refus par sécurité.
return false;
}
// ---------------------------------------------------------------------------
// Route matching
// ---------------------------------------------------------------------------
/**
* Teste un pattern de route contre un chemin de requête.
*
* Supporte :
* - Segments exacts : '/health'
* - Paramètres nommés : '/users/:id'
* - Wildcard greedy (fin uniquement) : '/storage/**'
*
* @param {string} pattern
* @param {string} path
* @returns {boolean}
*/
function matchRoute(pattern, path) {
const patternParts = pattern.split('/').filter(Boolean);
const pathParts = path.split('/').filter(Boolean);
const hasWildcard = patternParts[patternParts.length - 1] === '**';
if (hasWildcard) {
const fixedParts = patternParts.slice(0, -1);
if (pathParts.length < fixedParts.length) return false;
for (let i = 0; i < fixedParts.length; i++) {
if (fixedParts[i].startsWith(':')) continue;
if (fixedParts[i] !== pathParts[i]) return false;
}
return true;
}
if (patternParts.length !== pathParts.length) return false;
for (let i = 0; i < patternParts.length; i++) {
if (patternParts[i].startsWith(':')) continue;
if (patternParts[i] !== pathParts[i]) return false;
}
return true;
}
/**
* Extrait les paramètres de chemin nommés (et le wildcard) d'une route matchée.
*
* @param {string} pattern - Ex. '/users/:id'
* @param {string} path - Ex. '/users/42'
* @returns {Object} params — paramètres nommés + `wildcard` optionnel
*/
function extractPathParams(pattern, path) {
const params = {};
const patternParts = pattern.split('/').filter(Boolean);
const pathParts = path.split('/').filter(Boolean);
for (let i = 0; i < patternParts.length; i++) {
const part = patternParts[i];
if (part === '**') {
params.wildcard = pathParts.slice(i).join('/');
break;
}
if (part.startsWith(':')) {
params[part.slice(1)] = pathParts[i];
}
}
return params;
}
// ---------------------------------------------------------------------------
// Main router
// ---------------------------------------------------------------------------
// Messages sûrs à exposer verbatim au client.
const SAFE_AUTH_MESSAGES = new Set(['Unauthorized', 'Admin access required']);
// Émis au plus une fois par lifetime de process pour éviter le log flooding.
let _rateLimitUnavailableWarned = false;
/**
* Route une requête API vers le handler approprié.
*
* @param {Request} request - Requête Next.js entrante
* @param {string[]} path - Segments de chemin après /zen/api/
* @returns {Promise<Object>} Payload de réponse (sérialisé en JSON par route-handler.js)
*/
export async function routeRequest(request, path) {
const method = request.method;
const pathString = '/' + path.join('/');
// Fusion de toutes les routes — core en premier pour que les built-ins aient priorité.
// Le rate limit est différé après le matching pour pouvoir honorer skipRateLimit
// sans hardcoder de chemins dans le router.
const allRoutes = [...getCoreRoutes(), ...getFeatureRoutes()];
const matchedRoute = allRoutes.find(
route => matchRoute(route.path, pathString) && route.method === method
);
// Rate limit par IP. Les routes avec skipRateLimit: true sont exemptées
// (ex. GET /health pour que les sondes de monitoring ne consomment pas de quota).
if (!matchedRoute?.skipRateLimit) {
const ip = getIpFromRequest(request);
if (ip === 'unknown') {
// L'IP client ne peut pas être résolue — appliquer le rate limit sur la clé
// 'unknown' partagée effondrerait tout le trafic dans un seul bucket, permettant
// à un seul attaquant d'épuiser le quota et de dénier le service à tous les autres.
// Le rate limiting est donc suspendu jusqu'à ce qu'un reverse proxy de confiance
// soit configuré avec ZEN_TRUST_PROXY=true.
if (!_rateLimitUnavailableWarned) {
_rateLimitUnavailableWarned = true;
fail(
'Rate limiting inactive: client IP cannot be determined. ' +
'Set ZEN_TRUST_PROXY=true behind a verified reverse proxy to enable per-IP rate limiting.'
);
}
} else {
const rl = checkRateLimit(ip, 'api');
if (!rl.allowed) {
return apiError(
'Too Many Requests',
`Trop de requêtes. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.`
);
}
}
}
// Validation CSRF pour les requêtes state-mutating.
if (!passesCsrfCheck(request)) {
return apiError('Forbidden', 'CSRF validation failed');
}
if (!matchedRoute) {
// Aucune route matchée — message générique sans refléter la méthode ou le chemin
// pour éviter l'énumération de routes.
return apiError('Not Found', 'The requested resource does not exist');
}
// Enforcement auth depuis la définition de route, avant d'appeler le handler.
const context = {};
try {
if (matchedRoute.auth === 'admin') {
context.session = await requireAdmin();
if (matchedRoute.permission) {
const allowed = await hasPermission(context.session.user.id, matchedRoute.permission);
if (!allowed) {
return apiError('Forbidden', 'Permission insuffisante');
}
}
} else if (matchedRoute.auth === 'user') {
context.session = await requireAuth();
}
// 'public' — context.session reste undefined
} catch (err) {
const code = SAFE_AUTH_MESSAGES.has(err.message) ? err.message : 'Internal Server Error';
if (!SAFE_AUTH_MESSAGES.has(err.message)) {
fail(`Auth error: ${err.message}`);
}
return apiError(code, code);
}
const params = extractPathParams(matchedRoute.path, pathString);
try {
return await matchedRoute.handler(request, params, context);
} catch (err) {
fail(`Route handler error [${method} ${matchedRoute.path}]: ${err.message}`);
return apiError('Internal Server Error', 'An unexpected error occurred. Please try again later.');
}
}