From 59fce3cd91b07bc8bb06550eb04aed84680690c3 Mon Sep 17 00:00:00 2001 From: Hyko Date: Mon, 13 Apr 2026 17:20:14 -0400 Subject: [PATCH] refactor(api): update README and refactor api route registration Restructure the core API to separate infrastructure routes from feature routes. Key changes: - Add `runtime.js` for global state: session resolver and feature route registry - Add `file-response.js` for streaming file responses (storage endpoint) - Remove feature routes (auth/users) from `core-routes.js`, keeping only true infrastructure routes (health, storage) - Introduce `registerFeatureRoutes()` so features self-register during `initializeZen()` instead of being hardcoded in `core-routes.js` - Add `UserFacingError` class to safely surface client-facing errors without leaking internal details - Fix import path for `rateLimit.js` to use shared lib location - Update README to reflect new two-step registration flow and clarify the role of `core-routes.js` --- src/core/api/README.md | 24 +- src/core/api/core-routes.js | 18 +- src/core/api/define.js | 19 +- src/core/api/file-response.js | 57 +++++ src/core/api/health.js | 2 +- src/core/api/index.js | 9 +- src/core/api/respond.js | 33 ++- src/core/api/route-handler.js | 209 ++++-------------- src/core/api/router.js | 208 ++++++++--------- src/core/api/runtime.js | 99 +++++++++ src/core/storage/api.js | 4 +- src/features/auth/actions/authActions.js | 189 +++++++++++----- src/shared/lib/init.js | 19 +- .../auth => shared}/lib/rateLimit.js | 5 +- 14 files changed, 515 insertions(+), 380 deletions(-) create mode 100644 src/core/api/file-response.js create mode 100644 src/core/api/runtime.js rename src/{features/auth => shared}/lib/rateLimit.js (95%) diff --git a/src/core/api/README.md b/src/core/api/README.md index 11bf8a7..fadab97 100644 --- a/src/core/api/README.md +++ b/src/core/api/README.md @@ -10,10 +10,12 @@ Ce répertoire est un **framework d'API générique**. Il ne connaît aucune fea src/core/api/ ├── index.js Exports publics (routeRequest, requireAuth, apiSuccess, defineApiRoutes…) ├── router.js Orchestration : rate limit, CSRF, auth, dispatch +├── runtime.js État global persisté : resolver de session + registre des feature routes ├── route-handler.js Intégration Next.js App Router (GET/POST/PUT/DELETE/PATCH) ├── define.js defineApiRoutes() — validateur de définitions de routes -├── respond.js apiSuccess() / apiError() — utilitaires de réponse +├── respond.js apiSuccess() / apiError() / getStatusCode() — utilitaires de réponse ├── core-routes.js Index des routes built-in (seul fichier à toucher pour un nouveau handler core) +├── file-response.js Réponse streaming pour les fichiers (GET /zen/api/storage/**) └── health.js GET /zen/api/health src/features/auth/api.js Routes /zen/api/users/* (vivent avec la feature auth) @@ -54,9 +56,11 @@ L'auth est **toujours déclarée dans la définition de route**, jamais dans le --- -## Ajouter des routes à une feature existante +## Ajouter des routes à une feature core -Créer un fichier `api.js` dans le répertoire de la feature et exporter `routes` : +Deux étapes : + +**1. Créer un fichier `api.js` dans le répertoire de la feature :** ```js // src/features/myfeature/api.js @@ -79,23 +83,19 @@ export const routes = defineApiRoutes([ ]); ``` -Puis enregistrer dans `core-routes.js` (la seule ligne à ajouter) : +**2. L'enregistrer dans `initializeZen()` (src/shared/lib/init.js) :** ```js +import { registerFeatureRoutes } from '../../core/api/index.js'; import { routes as settingsRoutes } from '../../features/myfeature/api.js'; -export function getCoreRoutes() { - return [ - ...healthRoutes, - ...usersRoutes, - ...storageRoutes, - ...settingsRoutes, // ← ajout - ]; -} +registerFeatureRoutes(settingsRoutes); ``` **C'est tout.** Les routes sont disponibles à `GET /zen/api/settings` et `PUT /zen/api/settings`. +> Note : `core-routes.js` n'est utilisé que pour les routes built-in de l'infrastructure (health, storage). Les routes de features passent par `registerFeatureRoutes()` dans `initializeZen()`. + --- ## Ajouter des routes depuis un module diff --git a/src/core/api/core-routes.js b/src/core/api/core-routes.js index 3086db3..20c6d4e 100644 --- a/src/core/api/core-routes.js +++ b/src/core/api/core-routes.js @@ -1,29 +1,31 @@ /** * Core Route Index * - * This is the ONLY file to edit when adding a new built-in handler. - * Each feature manages its own route definitions via defineApiRoutes(). - * Do NOT put route logic here — only import and spread. + * Contains only routes that are part of the core API infrastructure itself: + * the health check and the storage file-serving endpoint. Both live under + * src/core/ and have no feature-level dependencies. * - * To add a new built-in handler: - * 1. Create the handler in its natural location (e.g. src/features/myfeature/api.js) + * Feature routes (e.g. /users/*) are registered separately by each feature + * during initializeZen() via registerFeatureRoutes(). Module routes are + * collected via getAllApiRoutes() from the module registry. + * + * To add a new core infrastructure handler: + * 1. Create the handler in its natural location under src/core/ * 2. Export `routes` from it using defineApiRoutes() * 3. Add one line here: import + spread * 4. Done — never touch router.js */ import { routes as healthRoutes } from './health.js'; -import { routes as usersRoutes } from '../../features/auth/api.js'; import { routes as storageRoutes } from '../storage/api.js'; /** - * Return all registered core API routes. + * Return all registered core infrastructure API routes. * @returns {ReadonlyArray} */ export function getCoreRoutes() { return [ ...healthRoutes, - ...usersRoutes, ...storageRoutes, ]; } diff --git a/src/core/api/define.js b/src/core/api/define.js index 3581dbe..0dff974 100644 --- a/src/core/api/define.js +++ b/src/core/api/define.js @@ -13,10 +13,16 @@ * ]); * * Required fields per route: - * path {string} Must start with '/'. Supports ':param' and trailing '/**'. - * method {string} One of: GET | POST | PUT | PATCH | DELETE - * handler {Function} Async function — signature: (request, params, context) - * auth {string} One of: 'public' | 'user' | 'admin' + * path {string} Must start with '/'. Supports ':param' and trailing '/**'. + * method {string} One of: GET | POST | PUT | PATCH | DELETE + * handler {Function} Async function — signature: (request, params, context) + * auth {string} One of: 'public' | 'user' | 'admin' + * + * Optional fields per route: + * skipRateLimit {boolean} When true, the router skips the per-IP rate limit + * check for this route. Use sparingly — only for routes + * that must remain accessible under high probe frequency + * (e.g. health checks from monitoring systems). * * Auth levels: * 'public' Anyone can call this route. context.session is undefined. @@ -66,6 +72,11 @@ export function defineApiRoutes(routes) { `${at} (${route.method} ${route.path}) — "auth" must be one of "public" | "user" | "admin", got: ${JSON.stringify(route.auth)}` ); } + if (route.skipRateLimit !== undefined && typeof route.skipRateLimit !== 'boolean') { + throw new TypeError( + `${at} (${route.method} ${route.path}) — "skipRateLimit" must be a boolean when provided, got: ${JSON.stringify(route.skipRateLimit)}` + ); + } } // Freeze to prevent accidental mutation of route definitions at runtime. diff --git a/src/core/api/file-response.js b/src/core/api/file-response.js new file mode 100644 index 0000000..c0e52e5 --- /dev/null +++ b/src/core/api/file-response.js @@ -0,0 +1,57 @@ +/** + * File Response Builder + * + * Builds a NextResponse for streaming a file from storage. + * Encapsulates all file-serving semantics — MIME type rendering policy, + * Content-Disposition rules, security headers — in one place so the + * generic route handler stays agnostic about storage concerns. + * + * Policy: + * - Image MIME types are served inline (required for tags). + * - All other types force a download to prevent in-browser rendering + * of potentially dangerous content. + * - Content-Disposition is always emitted explicitly; omitting it leaves + * rendering decisions to browser heuristics, which vary by content-type + * and browser version. + */ + +import { NextResponse } from 'next/server'; + +// MIME types that are safe to render inline (used in tags). +// All other types are forced to download. +const INLINE_MIME_TYPES = new Set([ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', +]); + +/** + * Build a NextResponse that streams a file from a storage result envelope. + * + * @param {{ body: *, contentType?: string, contentLength?: number, lastModified?: Date, filename?: string }} file + * @returns {NextResponse} + */ +export function buildFileResponse(file) { + const contentType = file.contentType || 'application/octet-stream'; + + const headers = { + 'Content-Type': contentType, + 'Cache-Control': 'private, max-age=3600', + 'Last-Modified': file.lastModified?.toUTCString() ?? new Date().toUTCString(), + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + }; + + if (file.contentLength != null) { + headers['Content-Length'] = String(file.contentLength); + } + + if (INLINE_MIME_TYPES.has(contentType)) { + headers['Content-Disposition'] = 'inline'; + } else if (file.filename) { + const encoded = encodeURIComponent(file.filename); + headers['Content-Disposition'] = `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`; + } else { + headers['Content-Disposition'] = 'attachment'; + } + + return new NextResponse(file.body, { status: 200, headers }); +} diff --git a/src/core/api/health.js b/src/core/api/health.js index 7e65e9c..289adc4 100644 --- a/src/core/api/health.js +++ b/src/core/api/health.js @@ -16,5 +16,5 @@ async function handleHealth() { } export const routes = defineApiRoutes([ - { path: '/health', method: 'GET', handler: handleHealth, auth: 'public' } + { path: '/health', method: 'GET', handler: handleHealth, auth: 'public', skipRateLimit: true } ]); diff --git a/src/core/api/index.js b/src/core/api/index.js index d4f87b9..d5c20df 100644 --- a/src/core/api/index.js +++ b/src/core/api/index.js @@ -2,14 +2,17 @@ * Zen API — Public Surface * * Exports the router entry point, auth helpers, response utilities, - * and the route definition helper for use across the application. + * the route definition helper, and the feature routes registry. */ // Router -export { routeRequest, getStatusCode, requireAuth, requireAdmin } from './router.js'; +export { routeRequest, requireAuth, requireAdmin } from './router.js'; + +// Runtime state — session resolver + feature routes registry +export { configureRouter, getSessionResolver, clearRouterConfig, registerFeatureRoutes, getFeatureRoutes, clearFeatureRoutes } from './runtime.js'; // Response utilities — use in all handlers (core and modules) -export { apiSuccess, apiError } from './respond.js'; +export { apiSuccess, apiError, getStatusCode } from './respond.js'; // Route definition helper — use in handler files and module api.js files export { defineApiRoutes } from './define.js'; diff --git a/src/core/api/respond.js b/src/core/api/respond.js index f5a14d6..b4a4466 100644 --- a/src/core/api/respond.js +++ b/src/core/api/respond.js @@ -29,9 +29,8 @@ export function apiSuccess(payload) { /** * Create an error API response payload. * - * The `code` field is read by getStatusCode() in router.js to derive the - * HTTP status. Always use one of the recognised codes below — any other - * value maps to 500. + * The `code` field is read by getStatusCode() to derive the HTTP status. + * Always use one of the recognised codes below — any other value maps to 500. * * Valid codes → HTTP status: * 'Unauthorized' → 401 @@ -49,3 +48,31 @@ export function apiSuccess(payload) { export function apiError(code, message) { return { error: code, message }; } + +/** + * Derive an HTTP status code from a response payload. + * Reads the `error` field set by apiError(). + * + * @param {Object} response + * @returns {number} + */ +export function getStatusCode(response) { + if (response.error) { + switch (response.error) { + case 'Unauthorized': + return 401; + case 'Forbidden': + case 'Admin access required': + return 403; + case 'Not Found': + return 404; + case 'Bad Request': + return 400; + case 'Too Many Requests': + return 429; + default: + return 500; + } + } + return 200; +} diff --git a/src/core/api/route-handler.js b/src/core/api/route-handler.js index 89fad44..b2daf9e 100644 --- a/src/core/api/route-handler.js +++ b/src/core/api/route-handler.js @@ -1,184 +1,51 @@ /** * ZEN API Route Handler - * - * This is the main catch-all route handler for the ZEN API under /zen/api/. - * It should be used in a Next.js App Router route at: app/zen/api/[...path]/route.js + * + * Catch-all Next.js App Router handler for all routes under /zen/api/. + * Place this file at: app/zen/api/[...path]/route.js + * + * All HTTP methods are handled by a single factory. GET additionally + * supports file streaming responses from the storage endpoint. */ import { NextResponse } from 'next/server'; -import { routeRequest, getStatusCode } from './router.js'; +import { routeRequest } from './router.js'; +import { apiError, getStatusCode } from './respond.js'; +import { buildFileResponse } from './file-response.js'; import { fail } from '../../shared/lib/logger.js'; -/** - * Handle GET requests - */ -export async function GET(request, { params }) { - try { - const resolvedParams = await params; - const path = resolvedParams.path || []; - const response = await routeRequest(request, path); - - // Check if this is a file response (from storage endpoint) - if (response.success && response.file) { - const contentType = response.file.contentType || 'application/octet-stream'; - const headers = { - 'Content-Type': contentType, - 'Content-Length': response.file.contentLength?.toString() || '', - 'Cache-Control': 'private, max-age=3600', - 'Last-Modified': response.file.lastModified?.toUTCString() || new Date().toUTCString(), - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', - }; +const GENERIC_ERROR_MSG = 'An unexpected error occurred. Please try again later.'; - // Always emit an explicit Content-Disposition header — omitting it leaves - // rendering decisions to browser heuristics, which varies by content-type - // and browser version. Image MIME types are served inline (required for - // tags); every other type forces a download to prevent in-browser - // rendering of potentially dangerous content. - const INLINE_MIME_TYPES = new Set([ - 'image/jpeg', 'image/png', 'image/gif', 'image/webp', - ]); - if (INLINE_MIME_TYPES.has(contentType)) { - headers['Content-Disposition'] = 'inline'; - } else if (response.file.filename) { - const encoded = encodeURIComponent(response.file.filename); - headers['Content-Disposition'] = `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`; - } else { - headers['Content-Disposition'] = 'attachment'; +/** + * Create a Next.js route handler for a given HTTP method. + * + * @param {boolean} [serveFiles=false] - When true, file streaming responses are + * returned directly instead of being wrapped in JSON. Only GET needs this. + * @returns {Function} Next.js App Router handler + */ +function makeHandler(serveFiles = false) { + return async function handler(request, { params }) { + try { + const path = (await params).path ?? []; + const response = await routeRequest(request, path); + + if (serveFiles && response.success && response.file) { + return buildFileResponse(response.file); } - return new NextResponse(response.file.body, { status: 200, headers }); + return NextResponse.json(response, { status: getStatusCode(response) }); + } catch (error) { + fail(`API error: ${error.message}`); + return NextResponse.json( + apiError('Internal Server Error', GENERIC_ERROR_MSG), + { status: 500 } + ); } - - // Regular JSON response - const statusCode = getStatusCode(response); - return NextResponse.json(response, { - status: statusCode, - headers: { - 'Content-Type': 'application/json' - } - }); - } catch (error) { - fail(`API error: ${error.message}`); - return NextResponse.json( - { - error: 'Internal Server Error', - message: 'An unexpected error occurred. Please try again later.' - }, - { status: 500 } - ); - } -} - -/** - * Handle POST requests - */ -export async function POST(request, { params }) { - try { - const resolvedParams = await params; - const path = resolvedParams.path || []; - const response = await routeRequest(request, path); - const statusCode = getStatusCode(response); - - return NextResponse.json(response, { - status: statusCode, - headers: { - 'Content-Type': 'application/json' - } - }); - } catch (error) { - fail(`API error: ${error.message}`); - return NextResponse.json( - { - error: 'Internal Server Error', - message: 'An unexpected error occurred. Please try again later.' - }, - { status: 500 } - ); - } -} - -/** - * Handle PUT requests - */ -export async function PUT(request, { params }) { - try { - const resolvedParams = await params; - const path = resolvedParams.path || []; - const response = await routeRequest(request, path); - const statusCode = getStatusCode(response); - - return NextResponse.json(response, { - status: statusCode, - headers: { - 'Content-Type': 'application/json' - } - }); - } catch (error) { - fail(`API error: ${error.message}`); - return NextResponse.json( - { - error: 'Internal Server Error', - message: 'An unexpected error occurred. Please try again later.' - }, - { status: 500 } - ); - } -} - -/** - * Handle DELETE requests - */ -export async function DELETE(request, { params }) { - try { - const resolvedParams = await params; - const path = resolvedParams.path || []; - const response = await routeRequest(request, path); - const statusCode = getStatusCode(response); - - return NextResponse.json(response, { - status: statusCode, - headers: { - 'Content-Type': 'application/json' - } - }); - } catch (error) { - fail(`API error: ${error.message}`); - return NextResponse.json( - { - error: 'Internal Server Error', - message: 'An unexpected error occurred. Please try again later.' - }, - { status: 500 } - ); - } -} - -/** - * Handle PATCH requests - */ -export async function PATCH(request, { params }) { - try { - const resolvedParams = await params; - const path = resolvedParams.path || []; - const response = await routeRequest(request, path); - const statusCode = getStatusCode(response); - - return NextResponse.json(response, { - status: statusCode, - headers: { - 'Content-Type': 'application/json' - } - }); - } catch (error) { - fail(`API error: ${error.message}`); - return NextResponse.json( - { - error: 'Internal Server Error', - message: 'An unexpected error occurred. Please try again later.' - }, - { status: 500 } - ); - } + }; } +export const GET = makeHandler(true); +export const POST = makeHandler(); +export const PUT = makeHandler(); +export const PATCH = makeHandler(); +export const DELETE = makeHandler(); diff --git a/src/core/api/router.js b/src/core/api/router.js index 0ed0377..5b0bfff 100644 --- a/src/core/api/router.js +++ b/src/core/api/router.js @@ -1,28 +1,34 @@ /** * API Router * - * Generic request router — has no knowledge of specific features. - * Core handlers and modules self-register their routes; this file - * only orchestrates rate limiting, CSRF, auth enforcement, and dispatch. + * 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. * - * Request lifecycle: + * 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 check (health GET exempt) - * → CSRF origin validation (state-mutating methods only) - * → unified route match (core routes first, then module routes) - * → auth enforcement from route definition + * → 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 { validateSession } from '../../features/auth/lib/session.js'; import { cookies } from 'next/headers'; import { getSessionCookieName } from '../../shared/lib/appConfig.js'; import { getAllApiRoutes } from '../modules/index.js'; -import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../features/auth/lib/rateLimit.js'; +import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../shared/lib/rateLimit.js'; import { fail } from '../../shared/lib/logger.js'; 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(); // --------------------------------------------------------------------------- @@ -30,11 +36,10 @@ const COOKIE_NAME = getSessionCookieName(); // --------------------------------------------------------------------------- /** - * Require a valid session. Throws if the request carries no valid cookie. - * @param {Request} request + * Exige une session valide. Lève une erreur si aucun cookie valide n'est présent. * @returns {Promise} session */ -export async function requireAuth(_request) { +export async function requireAuth() { const cookieStore = await cookies(); const sessionToken = cookieStore.get(COOKIE_NAME)?.value; @@ -42,7 +47,7 @@ export async function requireAuth(_request) { throw new Error('Unauthorized'); } - const session = await validateSession(sessionToken); + const session = await getSessionResolver()(sessionToken); if (!session || !session.user) { throw new Error('Unauthorized'); @@ -52,12 +57,11 @@ export async function requireAuth(_request) { } /** - * Require a valid admin session. Throws if not authenticated or not admin. - * @param {Request} request + * Exige une session admin valide. Lève une erreur si non authentifié ou non admin. * @returns {Promise} session */ -export async function requireAdmin(_request) { - const session = await requireAuth(_request); +export async function requireAdmin() { + const session = await requireAuth(); if (session.user.role !== 'admin') { throw new Error('Admin access required'); @@ -71,8 +75,8 @@ export async function requireAdmin(_request) { // --------------------------------------------------------------------------- /** - * Resolve the canonical application URL from environment variables. - * Priority: NEXT_PUBLIC_URL_DEV (development) → NEXT_PUBLIC_URL (production). + * 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) { @@ -82,8 +86,8 @@ function resolveAppUrl() { } /** - * Verify that state-mutating requests originate from the expected application - * origin. GET, HEAD, and OPTIONS are exempt per RFC 7231. + * 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} */ @@ -110,7 +114,7 @@ function passesCsrfCheck(request) { return origin === expectedOrigin; } - // No Origin header: fall back to Referer (some older browsers). + // Pas d'en-tête Origin : repli sur Referer (anciens navigateurs). const referer = request.headers.get('referer'); if (referer) { try { @@ -120,7 +124,7 @@ function passesCsrfCheck(request) { } } - // Neither Origin nor Referer — deny to be safe. + // Ni Origin ni Referer — refus par sécurité. return false; } @@ -129,15 +133,15 @@ function passesCsrfCheck(request) { // --------------------------------------------------------------------------- /** - * Match a route pattern against a request path. + * Teste un pattern de route contre un chemin de requête. * - * Supports: - * - Exact segments: '/health' - * - Named params: '/users/:id' - * - Greedy wildcard (end only): '/storage/**' + * Supporte : + * - Segments exacts : '/health' + * - Paramètres nommés : '/users/:id' + * - Wildcard greedy (fin uniquement) : '/storage/**' * - * @param {string} pattern - Route pattern - * @param {string} path - Actual request path (e.g. '/users/42') + * @param {string} pattern + * @param {string} path * @returns {boolean} */ function matchRoute(pattern, path) { @@ -167,11 +171,11 @@ function matchRoute(pattern, path) { } /** - * Extract named path parameters (and wildcard) from a matched route. + * Extrait les paramètres de chemin nommés (et le wildcard) d'une route matchée. * - * @param {string} pattern - Route pattern (e.g. '/users/:id') - * @param {string} path - Actual path (e.g. '/users/42') - * @returns {Object} params — named params + optional `wildcard` string + * @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 = {}; @@ -196,37 +200,42 @@ function extractPathParams(pattern, path) { // Main router // --------------------------------------------------------------------------- -// Messages safe to surface to clients verbatim. +// Messages sûrs à exposer verbatim au client. const SAFE_AUTH_MESSAGES = new Set(['Unauthorized', 'Admin access required']); -// Emitted at most once per process lifetime to avoid log flooding while still -// alerting operators that per-IP rate limiting is inactive. +// Émis au plus une fois par lifetime de process pour éviter le log flooding. let _rateLimitUnavailableWarned = false; /** - * Route an API request to the appropriate handler. + * Route une requête API vers le handler approprié. * - * @param {Request} request - Incoming Next.js request - * @param {string[]} path - Path segments after /zen/api/ - * @returns {Promise} Response payload (serialised to JSON by route-handler.js) + * @param {Request} request - Requête Next.js entrante + * @param {string[]} path - Segments de chemin après /zen/api/ + * @returns {Promise} 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('/'); - // IP-based rate limit for all API calls. The health endpoint is exempt so - // that monitoring probes do not consume quota. - const isHealthCheck = path[0] === 'health' && method === 'GET'; - if (!isHealthCheck) { + // 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(), ...getAllApiRoutes()]; + + 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') { - // Client IP cannot be resolved — applying rate limiting against the - // shared 'unknown' key would collapse every user's traffic into one - // bucket, allowing a single attacker to exhaust it and deny service to - // all other users (global DoS). Rate limiting is therefore suspended - // until a trusted reverse proxy is configured. - // Operators must set ZEN_TRUST_PROXY=true once a verified proxy - // (Nginx, Cloudflare, AWS ALB, …) strips and rewrites forwarding headers. + // 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( @@ -245,77 +254,40 @@ export async function routeRequest(request, path) { } } - // CSRF origin validation for state-mutating requests. + // Validation CSRF pour les requêtes state-mutating. if (!passesCsrfCheck(request)) { return apiError('Forbidden', 'CSRF validation failed'); } - // Merge all routes — core first so built-ins take precedence over modules. - const allRoutes = [...getCoreRoutes(), ...getAllApiRoutes()]; - - for (const route of allRoutes) { - if (!matchRoute(route.path, pathString) || route.method !== method) { - continue; - } - - // Enforce auth from the route definition before calling the handler. - const context = {}; - try { - if (route.auth === 'admin') { - context.session = await requireAdmin(request); - } else if (route.auth === 'user') { - context.session = await requireAuth(request); - } - // 'public' — context.session remains 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(route.path, pathString); - - try { - return await route.handler(request, params, context); - } catch (err) { - fail(`Route handler error [${method} ${route.path}]: ${err.message}`); - return apiError('Internal Server Error', 'An unexpected error occurred. Please try again later.'); - } + 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'); } - // No route matched — return a generic message without reflecting the method - // or path back to the caller to avoid route enumeration. - return apiError('Not Found', 'The requested resource does not exist'); -} - -// --------------------------------------------------------------------------- -// HTTP status mapping -// --------------------------------------------------------------------------- - -/** - * Derive an HTTP status code from the response payload. - * @param {Object} response - * @returns {number} - */ -export function getStatusCode(response) { - if (response.error) { - switch (response.error) { - case 'Unauthorized': - return 401; - case 'Forbidden': - case 'Admin access required': - return 403; - case 'Not Found': - return 404; - case 'Bad Request': - return 400; - case 'Too Many Requests': - return 429; - default: - return 500; + // Enforcement auth depuis la définition de route, avant d'appeler le handler. + const context = {}; + try { + if (matchedRoute.auth === 'admin') { + context.session = await requireAdmin(); + } 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.'); } - return 200; } diff --git a/src/core/api/runtime.js b/src/core/api/runtime.js new file mode 100644 index 0000000..e6d647d --- /dev/null +++ b/src/core/api/runtime.js @@ -0,0 +1,99 @@ +/** + * API Runtime State + * + * Centralise tout l'état global persisté de l'infrastructure API : + * - Le resolver de session (injecté par la feature auth via configureRouter) + * - Le registre des routes de features (peuplé via registerFeatureRoutes) + * + * Les deux utilisent le même pattern Symbol.for() + globalThis pour survivre + * aux hot-reloads Next.js sans réinitialiser l'état entre les recharges de modules. + * + * LIMITATION CONNUE : l'état est local au process. Dans un déploiement multi-worker + * ou serverless, chaque instance maintient son propre état. Pour les routes, cela + * implique que initializeZen() doit être appelé une fois par worker — ce qui est + * déjà le cas via instrumentation.js. + */ + +// --------------------------------------------------------------------------- +// Session resolver +// --------------------------------------------------------------------------- + +const RESOLVER_KEY = Symbol.for('__ZEN_SESSION_RESOLVER__'); + +/** + * Configure le router avec les dépendances de la feature auth. + * Doit être appelé une fois au démarrage avant toute requête. + * + * @param {{ resolveSession: (token: string) => Promise }} config + */ +export function configureRouter({ resolveSession }) { + if (typeof resolveSession !== 'function') { + throw new TypeError('configureRouter: resolveSession must be a function'); + } + globalThis[RESOLVER_KEY] = resolveSession; +} + +/** + * Retourne le resolver de session configuré. + * Utilisé par router.js et core/storage/api.js. + * + * @returns {(token: string) => Promise} + * @throws {Error} Si configureRouter n'a pas encore été appelé + */ +export function getSessionResolver() { + const resolver = globalThis[RESOLVER_KEY]; + if (!resolver) { + throw new Error( + 'Router not configured: call configureRouter({ resolveSession }) during initializeZen() before handling requests.' + ); + } + return resolver; +} + +/** + * Efface le resolver injecté. + * Destiné aux tests ou à la réinitialisation manuelle. + */ +export function clearRouterConfig() { + globalThis[RESOLVER_KEY] = undefined; +} + +// --------------------------------------------------------------------------- +// Feature routes registry +// --------------------------------------------------------------------------- + +const REGISTRY_KEY = Symbol.for('__ZEN_FEATURE_ROUTES__'); +if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = []; +/** @type {Array} */ +const _featureRoutes = globalThis[REGISTRY_KEY]; + +/** + * Enregistre les routes d'une feature core. + * Appelé une fois par feature pendant initializeZen(). + * + * @param {ReadonlyArray} routes - Définitions produites par defineApiRoutes() + */ +export function registerFeatureRoutes(routes) { + if (!Array.isArray(routes)) { + throw new TypeError('registerFeatureRoutes: routes must be an array'); + } + _featureRoutes.push(...routes); +} + +/** + * Retourne toutes les routes de features enregistrées. + * Appelé à chaque requête par le router pour construire la liste complète. + * + * @returns {ReadonlyArray} + */ +export function getFeatureRoutes() { + return _featureRoutes; +} + +/** + * Vide toutes les routes de features enregistrées. + * Destiné aux tests ou à la réinitialisation de l'état ZEN. + */ +export function clearFeatureRoutes() { + _featureRoutes.length = 0; +} diff --git a/src/core/storage/api.js b/src/core/storage/api.js index fdad9e8..c099943 100644 --- a/src/core/storage/api.js +++ b/src/core/storage/api.js @@ -13,9 +13,9 @@ * - Unknown paths → denied */ -import { validateSession } from '../../features/auth/lib/session.js'; import { cookies } from 'next/headers'; import { getSessionCookieName } from '../../shared/lib/appConfig.js'; +import { getSessionResolver } from '../api/router.js'; import { getAllStoragePublicPrefixes } from '@zen/core/modules/storage'; import { getFile } from '@zen/core/storage'; import { fail } from '../../shared/lib/logger.js'; @@ -72,7 +72,7 @@ async function handleGetFile(request, { wildcard: fileKey }) { return apiError('Unauthorized', 'Authentication required to access files'); } - const session = await validateSession(sessionToken); + const session = await getSessionResolver()(sessionToken); if (!session) { return apiError('Unauthorized', 'Invalid or expired session'); diff --git a/src/features/auth/actions/authActions.js b/src/features/auth/actions/authActions.js index 1d464a3..48c7ee2 100644 --- a/src/features/auth/actions/authActions.js +++ b/src/features/auth/actions/authActions.js @@ -11,7 +11,20 @@ import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPassword import { fail } from '../../../shared/lib/logger.js'; import { cookies, headers } from 'next/headers'; import { getSessionCookieName, getPublicBaseUrl } from '../../../shared/lib/appConfig.js'; -import { checkRateLimit, getIpFromHeaders, formatRetryAfter } from '../lib/rateLimit.js'; +import { checkRateLimit, getIpFromHeaders, formatRetryAfter } from '../../../shared/lib/rateLimit.js'; + +/** + * Errors that are safe to surface verbatim to the client (e.g. "token expired"). + * All other errors — including library-layer and database errors — must be caught, + * logged server-side only, and replaced with a generic message to prevent internal + * detail disclosure. + */ +class UserFacingError extends Error { + constructor(message) { + super(message); + this.name = 'UserFacingError'; + } +} /** * Get the client IP from the current server action context. @@ -21,10 +34,49 @@ async function getClientIp() { return getIpFromHeaders(h); } +// Emitted at most once per process lifetime to avoid log flooding while still +// alerting operators that per-IP rate limiting is inactive for server actions. +let _rateLimitUnavailableWarned = false; + +/** + * Apply per-IP rate limiting only when a real IP address is available. + * + * When IP resolves to 'unknown' (no trusted proxy configured), every caller + * shares the single bucket keyed ':unknown'. A single attacker can + * exhaust that bucket in 5 requests and impose a 30-minute denial-of-service + * on every legitimate user. Rate limiting is therefore suspended for the + * 'unknown' case and a one-time operator warning is emitted instead, + * mirroring the identical policy applied to API routes in router.js. + * + * Set ZEN_TRUST_PROXY=true only behind a verified reverse proxy (Nginx, + * Cloudflare, AWS ALB, …) that strips and rewrites forwarding headers. + * + * @param {string} ip + * @param {string} action + * @returns {{ allowed: boolean, retryAfterMs?: number } | null} null = suspended + */ +function enforceRateLimit(ip, action) { + if (ip === 'unknown') { + if (!_rateLimitUnavailableWarned) { + _rateLimitUnavailableWarned = true; + fail( + 'Rate limiting inactive (server actions): client IP cannot be determined. ' + + 'Set ZEN_TRUST_PROXY=true behind a verified reverse proxy to enable per-IP rate limiting.' + ); + } + return null; + } + return checkRateLimit(ip, action); +} + /** * Validate anti-bot fields submitted with forms. * - _hp : honeypot field — must be empty - * - _t : form load timestamp (ms) — submission must be at least 1.5 s after page load + * - _t : form load timestamp (ms) — submission must be at least 1.5 s after page + * load AND no more than MAX_FORM_AGE_MS in the past. Both a lower bound + * (prevents instant automated submission) and an upper bound (prevents the + * trivial bypass of supplying an arbitrary past timestamp such as _t=1) are + * enforced. Future timestamps are also rejected. * * @param {FormData} formData * @returns {{ valid: boolean, error?: string }} @@ -35,8 +87,13 @@ function validateAntiBotFields(formData) { return { valid: false, error: 'Requête invalide' }; } - const t = parseInt(formData.get('_t') || '0', 10); - if (t === 0 || Date.now() - t < 1500) { + const MIN_ELAPSED_MS = 1_500; + const MAX_FORM_AGE_MS = 10 * 60 * 1_000; // 10 minutes — rejects epoch-era timestamps + const now = Date.now(); + const t = parseInt(formData.get('_t') || '0', 10); + const elapsed = now - t; + + if (t === 0 || t > now || elapsed < MIN_ELAPSED_MS || elapsed > MAX_FORM_AGE_MS) { return { valid: false, error: 'Requête invalide' }; } @@ -57,8 +114,8 @@ export async function registerAction(formData) { if (!botCheck.valid) return { success: false, error: botCheck.error }; const ip = await getClientIp(); - const rl = checkRateLimit(ip, 'register'); - if (!rl.allowed) { + const rl = enforceRateLimit(ip, 'register'); + if (rl && !rl.allowed) { return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` }; } @@ -77,10 +134,10 @@ export async function registerAction(formData) { user: result.user }; } catch (error) { - return { - success: false, - error: error.message - }; + // Never return raw error.message to the client — library and database errors + // (e.g. unique-constraint violations) expose internal table names and schema. + fail(`Auth: registerAction error: ${error.message}`); + return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' }; } } @@ -95,8 +152,8 @@ export async function loginAction(formData) { if (!botCheck.valid) return { success: false, error: botCheck.error }; const ip = await getClientIp(); - const rl = checkRateLimit(ip, 'login'); - if (!rl.allowed) { + const rl = enforceRateLimit(ip, 'login'); + if (rl && !rl.allowed) { return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` }; } @@ -125,20 +182,34 @@ export async function loginAction(formData) { // JavaScript-accessible response body. }; } catch (error) { - return { - success: false, - error: error.message - }; + fail(`Auth: loginAction error: ${error.message}`); + return { success: false, error: 'Identifiants invalides ou erreur interne. Veuillez réessayer.' }; } } /** - * Set session cookie (called by client after showing success message) - * @param {string} token - Session token + * Set session cookie after verifying the token is a genuine live session. + * + * This server action is client-callable. Without server-side token validation an + * attacker could supply any arbitrary string (including a stolen token for another + * user) and have it written as the HttpOnly session cookie, completely bypassing + * the protection that HttpOnly is intended to provide. The token is therefore + * validated against the session store before the cookie is written. + * + * @param {string} token - Session token obtained from a trusted server-side flow * @returns {Promise} Result object */ export async function setSessionCookie(token) { try { + if (!token || typeof token !== 'string' || token.trim() === '') { + return { success: false, error: 'Jeton de session invalide' }; + } + + const session = await validateSession(token); + if (!session) { + return { success: false, error: 'Session invalide ou expirée' }; + } + const cookieStore = await cookies(); cookieStore.set(COOKIE_NAME, token, { httpOnly: true, @@ -150,20 +221,32 @@ export async function setSessionCookie(token) { return { success: true }; } catch (error) { - return { - success: false, - error: error.message - }; + fail(`Auth: setSessionCookie error: ${error.message}`); + return { success: false, error: 'Une erreur interne est survenue' }; } } /** - * Refresh session cookie (extend expiration) + * Refresh session cookie (extend expiration). + * + * Re-validates the token before extending its cookie lifetime so that expired + * or revoked tokens cannot have their cookie window reopened by replaying this + * server action. + * * @param {string} token - Session token * @returns {Promise} Result object */ export async function refreshSessionCookie(token) { try { + if (!token || typeof token !== 'string' || token.trim() === '') { + return { success: false, error: 'Jeton de session invalide' }; + } + + const session = await validateSession(token); + if (!session) { + return { success: false, error: 'Session invalide ou expirée' }; + } + const cookieStore = await cookies(); cookieStore.set(COOKIE_NAME, token, { httpOnly: true, @@ -175,10 +258,8 @@ export async function refreshSessionCookie(token) { return { success: true }; } catch (error) { - return { - success: false, - error: error.message - }; + fail(`Auth: refreshSessionCookie error: ${error.message}`); + return { success: false, error: 'Une erreur interne est survenue' }; } } @@ -202,10 +283,8 @@ export async function logoutAction() { message: 'Déconnexion réussie' }; } catch (error) { - return { - success: false, - error: error.message - }; + fail(`Auth: logoutAction error: ${error.message}`); + return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' }; } } @@ -245,8 +324,8 @@ export async function forgotPasswordAction(formData) { if (!botCheck.valid) return { success: false, error: botCheck.error }; const ip = await getClientIp(); - const rl = checkRateLimit(ip, 'forgot_password'); - if (!rl.allowed) { + const rl = enforceRateLimit(ip, 'forgot_password'); + if (rl && !rl.allowed) { return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` }; } @@ -263,10 +342,8 @@ export async function forgotPasswordAction(formData) { message: 'Si un compte existe avec cet e-mail, vous recevrez un lien pour réinitialiser votre mot de passe.' }; } catch (error) { - return { - success: false, - error: error.message - }; + fail(`Auth: forgotPasswordAction error: ${error.message}`); + return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' }; } } @@ -278,8 +355,8 @@ export async function forgotPasswordAction(formData) { export async function resetPasswordAction(formData) { try { const ip = await getClientIp(); - const rl = checkRateLimit(ip, 'reset_password'); - if (!rl.allowed) { + const rl = enforceRateLimit(ip, 'reset_password'); + if (rl && !rl.allowed) { return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` }; } @@ -287,10 +364,11 @@ export async function resetPasswordAction(formData) { const token = formData.get('token'); const newPassword = formData.get('newPassword'); - // Verify token first + // Verify token first — throw UserFacingError so the specific message reaches + // the client while unexpected system errors are sanitized in the catch below. const isValid = await verifyResetToken(email, token); if (!isValid) { - throw new Error('Jeton de réinitialisation invalide ou expiré'); + throw new UserFacingError('Jeton de réinitialisation invalide ou expiré'); } await resetPassword({ email, token, newPassword }); @@ -300,10 +378,11 @@ export async function resetPasswordAction(formData) { message: 'Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.' }; } catch (error) { - return { - success: false, - error: error.message - }; + if (error instanceof UserFacingError) { + return { success: false, error: error.message }; + } + fail(`Auth: resetPasswordAction error: ${error.message}`); + return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' }; } } @@ -315,18 +394,19 @@ export async function resetPasswordAction(formData) { export async function verifyEmailAction(formData) { try { const ip = await getClientIp(); - const rl = checkRateLimit(ip, 'verify_email'); - if (!rl.allowed) { + const rl = enforceRateLimit(ip, 'verify_email'); + if (rl && !rl.allowed) { return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` }; } const email = formData.get('email'); const token = formData.get('token'); - // Verify token + // Verify token — throw UserFacingError so validation messages surface to the + // client while unexpected system errors remain sanitized in the catch below. const isValid = await verifyEmailToken(email, token); if (!isValid) { - throw new Error('Jeton de vérification invalide ou expiré'); + throw new UserFacingError('Jeton de vérification invalide ou expiré'); } // Find user and verify @@ -334,7 +414,7 @@ export async function verifyEmailAction(formData) { const user = await findOne('zen_auth_users', { email }); if (!user) { - throw new Error('Utilisateur introuvable'); + throw new UserFacingError('Utilisateur introuvable'); } await verifyUserEmail(user.id); @@ -344,10 +424,11 @@ export async function verifyEmailAction(formData) { message: 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.' }; } catch (error) { - return { - success: false, - error: error.message - }; + if (error instanceof UserFacingError) { + return { success: false, error: error.message }; + } + fail(`Auth: verifyEmailAction error: ${error.message}`); + return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' }; } } diff --git a/src/shared/lib/init.js b/src/shared/lib/init.js index acd7a64..8e696ee 100644 --- a/src/shared/lib/init.js +++ b/src/shared/lib/init.js @@ -4,6 +4,9 @@ */ import { discoverModules, registerExternalModules, startModuleCronJobs, stopModuleCronJobs } from '../../core/modules/index.js'; +import { configureRouter, registerFeatureRoutes, clearFeatureRoutes, clearRouterConfig } from '../../core/api/index.js'; +import { validateSession } from '../../features/auth/lib/session.js'; +import { routes as authRoutes } from '../../features/auth/api.js'; import { step, done, warn, fail } from './logger.js'; // Use globalThis to persist initialization flag across module reloads @@ -64,7 +67,13 @@ export async function initializeZen(config = {}) { }; try { - // Step 1: Discover and register internal modules (from modules.registry.js) + // Step 1: Wire core feature dependencies into the API router. + // init.js is the composition root — the only place that wires features + // into core, keeping core/api/ free of static feature-level imports. + configureRouter({ resolveSession: validateSession }); + registerFeatureRoutes(authRoutes); + + // Step 2: Discover and register internal modules (from modules.registry.js) result.discovery = await discoverModules(); const enabledCount = result.discovery.enabled?.length || 0; @@ -77,7 +86,7 @@ export async function initializeZen(config = {}) { warn(`ZEN: skipped ${skippedCount} module(s): ${result.discovery.skipped.join(', ')}`); } - // Step 2: Register external modules from zen.config.js (if any) + // Step 3: Register external modules from zen.config.js (if any) if (externalModules.length > 0) { result.external = await registerExternalModules(externalModules); @@ -86,7 +95,7 @@ export async function initializeZen(config = {}) { } } - // Step 3: Start cron jobs for all enabled modules (internal + external) + // Step 4: Start cron jobs for all enabled modules (internal + external) if (!skipCron) { result.cron = await startModuleCronJobs(); @@ -119,5 +128,9 @@ export function resetZenInitialization() { // Cron system not available } + // Clear router config and feature routes so they are re-registered on next initializeZen() + clearRouterConfig(); + clearFeatureRoutes(); + warn('ZEN: initialization reset'); } diff --git a/src/features/auth/lib/rateLimit.js b/src/shared/lib/rateLimit.js similarity index 95% rename from src/features/auth/lib/rateLimit.js rename to src/shared/lib/rateLimit.js index 176591d..5d1a9ba 100644 --- a/src/features/auth/lib/rateLimit.js +++ b/src/shared/lib/rateLimit.js @@ -122,7 +122,10 @@ export function getIpFromHeaders(headersList) { const realIp = headersList.get('x-real-ip')?.trim(); if (realIp && isValidIp(realIp)) return realIp; } - // Safe fallback — all requests share the 'unknown' bucket. + // Fallback when no trusted proxy is configured. + // Callers (router.js, authActions.js) treat 'unknown' as a signal to suspend + // rate limiting rather than collapse all traffic into one shared bucket — which + // would allow a single attacker to exhaust the quota and deny service globally. // Configure ZEN_TRUST_PROXY=true behind a verified reverse proxy. return 'unknown'; }