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`
This commit is contained in:
2026-04-13 17:20:14 -04:00
parent a3921a0b98
commit 59fce3cd91
14 changed files with 515 additions and 380 deletions
+12 -12
View File
@@ -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
+10 -8
View File
@@ -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,
];
}
+15 -4
View File
@@ -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.
+57
View File
@@ -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 <img> 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 <img> 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 });
}
+1 -1
View File
@@ -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 }
]);
+6 -3
View File
@@ -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';
+30 -3
View File
@@ -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;
}
+38 -171
View File
@@ -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
// <img> 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();
+90 -118
View File
@@ -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<Object>} 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<Object>} 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<Object>} 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<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('/');
// 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;
}
+99
View File
@@ -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<Object|null> }} 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<Object|null>}
* @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;
}
+2 -2
View File
@@ -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');
+135 -54
View File
@@ -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 '<action>: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<Object>} 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<Object>} 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.' };
}
}
+16 -3
View File
@@ -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');
}
@@ -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';
}