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/ src/core/api/
├── index.js Exports publics (routeRequest, requireAuth, apiSuccess, defineApiRoutes…) ├── index.js Exports publics (routeRequest, requireAuth, apiSuccess, defineApiRoutes…)
├── router.js Orchestration : rate limit, CSRF, auth, dispatch ├── 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) ├── route-handler.js Intégration Next.js App Router (GET/POST/PUT/DELETE/PATCH)
├── define.js defineApiRoutes() — validateur de définitions de routes ├── 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) ├── 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 └── health.js GET /zen/api/health
src/features/auth/api.js Routes /zen/api/users/* (vivent avec la feature auth) 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 ```js
// src/features/myfeature/api.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 ```js
import { registerFeatureRoutes } from '../../core/api/index.js';
import { routes as settingsRoutes } from '../../features/myfeature/api.js'; import { routes as settingsRoutes } from '../../features/myfeature/api.js';
export function getCoreRoutes() { registerFeatureRoutes(settingsRoutes);
return [
...healthRoutes,
...usersRoutes,
...storageRoutes,
...settingsRoutes, // ← ajout
];
}
``` ```
**C'est tout.** Les routes sont disponibles à `GET /zen/api/settings` et `PUT /zen/api/settings`. **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 ## Ajouter des routes depuis un module
+10 -8
View File
@@ -1,29 +1,31 @@
/** /**
* Core Route Index * Core Route Index
* *
* This is the ONLY file to edit when adding a new built-in handler. * Contains only routes that are part of the core API infrastructure itself:
* Each feature manages its own route definitions via defineApiRoutes(). * the health check and the storage file-serving endpoint. Both live under
* Do NOT put route logic here — only import and spread. * src/core/ and have no feature-level dependencies.
* *
* To add a new built-in handler: * Feature routes (e.g. /users/*) are registered separately by each feature
* 1. Create the handler in its natural location (e.g. src/features/myfeature/api.js) * 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() * 2. Export `routes` from it using defineApiRoutes()
* 3. Add one line here: import + spread * 3. Add one line here: import + spread
* 4. Done — never touch router.js * 4. Done — never touch router.js
*/ */
import { routes as healthRoutes } from './health.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'; import { routes as storageRoutes } from '../storage/api.js';
/** /**
* Return all registered core API routes. * Return all registered core infrastructure API routes.
* @returns {ReadonlyArray} * @returns {ReadonlyArray}
*/ */
export function getCoreRoutes() { export function getCoreRoutes() {
return [ return [
...healthRoutes, ...healthRoutes,
...usersRoutes,
...storageRoutes, ...storageRoutes,
]; ];
} }
+15 -4
View File
@@ -13,10 +13,16 @@
* ]); * ]);
* *
* Required fields per route: * Required fields per route:
* path {string} Must start with '/'. Supports ':param' and trailing '/**'. * path {string} Must start with '/'. Supports ':param' and trailing '/**'.
* method {string} One of: GET | POST | PUT | PATCH | DELETE * method {string} One of: GET | POST | PUT | PATCH | DELETE
* handler {Function} Async function — signature: (request, params, context) * handler {Function} Async function — signature: (request, params, context)
* auth {string} One of: 'public' | 'user' | 'admin' * 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: * Auth levels:
* 'public' Anyone can call this route. context.session is undefined. * '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)}` `${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. // 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([ 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 * Zen API — Public Surface
* *
* Exports the router entry point, auth helpers, response utilities, * 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 // 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) // 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 // Route definition helper — use in handler files and module api.js files
export { defineApiRoutes } from './define.js'; export { defineApiRoutes } from './define.js';
+30 -3
View File
@@ -29,9 +29,8 @@ export function apiSuccess(payload) {
/** /**
* Create an error API response payload. * Create an error API response payload.
* *
* The `code` field is read by getStatusCode() in router.js to derive the * The `code` field is read by getStatusCode() to derive the HTTP status.
* HTTP status. Always use one of the recognised codes below — any other * Always use one of the recognised codes below — any other value maps to 500.
* value maps to 500.
* *
* Valid codes → HTTP status: * Valid codes → HTTP status:
* 'Unauthorized' → 401 * 'Unauthorized' → 401
@@ -49,3 +48,31 @@ export function apiSuccess(payload) {
export function apiError(code, message) { export function apiError(code, message) {
return { error: 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 * ZEN API Route Handler
* *
* This is the main catch-all route handler for the ZEN API under /zen/api/. * Catch-all Next.js App Router handler for all routes under /zen/api/.
* It should be used in a Next.js App Router route at: app/zen/api/[...path]/route.js * 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 { 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'; import { fail } from '../../shared/lib/logger.js';
/** const GENERIC_ERROR_MSG = 'An unexpected error occurred. Please try again later.';
* 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',
};
// Always emit an explicit Content-Disposition header — omitting it leaves /**
// rendering decisions to browser heuristics, which varies by content-type * Create a Next.js route handler for a given HTTP method.
// and browser version. Image MIME types are served inline (required for *
// <img> tags); every other type forces a download to prevent in-browser * @param {boolean} [serveFiles=false] - When true, file streaming responses are
// rendering of potentially dangerous content. * returned directly instead of being wrapped in JSON. Only GET needs this.
const INLINE_MIME_TYPES = new Set([ * @returns {Function} Next.js App Router handler
'image/jpeg', 'image/png', 'image/gif', 'image/webp', */
]); function makeHandler(serveFiles = false) {
if (INLINE_MIME_TYPES.has(contentType)) { return async function handler(request, { params }) {
headers['Content-Disposition'] = 'inline'; try {
} else if (response.file.filename) { const path = (await params).path ?? [];
const encoded = encodeURIComponent(response.file.filename); const response = await routeRequest(request, path);
headers['Content-Disposition'] = `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`;
} else { if (serveFiles && response.success && response.file) {
headers['Content-Disposition'] = 'attachment'; 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 * API Router
* *
* Generic request router — has no knowledge of specific features. * Orchestre rate limiting, CSRF, enforcement d'auth et dispatch vers les handlers.
* Core handlers and modules self-register their routes; this file * N'a aucune connaissance des features spécifiques — les routes s'auto-enregistrent.
* only orchestrates rate limiting, CSRF, auth enforcement, and dispatch.
* *
* 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() * route-handler.js → routeRequest()
* → rate limit check (health GET exempt) * → rate limit (routes skipRateLimit exemptées)
* → CSRF origin validation (state-mutating methods only) * → validation CSRF (méthodes state-mutating uniquement)
* → unified route match (core routes first, then module routes) * → matching sur toutes les routes (core en premier, puis features, puis modules)
* → auth enforcement from route definition * → enforcement auth depuis la définition de route
* → handler(request, params, context) * → handler(request, params, context)
*/ */
import { validateSession } from '../../features/auth/lib/session.js';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { getSessionCookieName } from '../../shared/lib/appConfig.js'; import { getSessionCookieName } from '../../shared/lib/appConfig.js';
import { getAllApiRoutes } from '../modules/index.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 { fail } from '../../shared/lib/logger.js';
import { getCoreRoutes } from './core-routes.js'; import { getCoreRoutes } from './core-routes.js';
import { getFeatureRoutes, getSessionResolver } from './runtime.js';
import { apiError } from './respond.js'; import { apiError } from './respond.js';
export { configureRouter, getSessionResolver, clearRouterConfig } from './runtime.js';
const COOKIE_NAME = getSessionCookieName(); const COOKIE_NAME = getSessionCookieName();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -30,11 +36,10 @@ const COOKIE_NAME = getSessionCookieName();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Require a valid session. Throws if the request carries no valid cookie. * Exige une session valide. Lève une erreur si aucun cookie valide n'est présent.
* @param {Request} request
* @returns {Promise<Object>} session * @returns {Promise<Object>} session
*/ */
export async function requireAuth(_request) { export async function requireAuth() {
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionToken = cookieStore.get(COOKIE_NAME)?.value; const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
@@ -42,7 +47,7 @@ export async function requireAuth(_request) {
throw new Error('Unauthorized'); throw new Error('Unauthorized');
} }
const session = await validateSession(sessionToken); const session = await getSessionResolver()(sessionToken);
if (!session || !session.user) { if (!session || !session.user) {
throw new Error('Unauthorized'); 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. * Exige une session admin valide. Lève une erreur si non authentifié ou non admin.
* @param {Request} request
* @returns {Promise<Object>} session * @returns {Promise<Object>} session
*/ */
export async function requireAdmin(_request) { export async function requireAdmin() {
const session = await requireAuth(_request); const session = await requireAuth();
if (session.user.role !== 'admin') { if (session.user.role !== 'admin') {
throw new Error('Admin access required'); throw new Error('Admin access required');
@@ -71,8 +75,8 @@ export async function requireAdmin(_request) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Resolve the canonical application URL from environment variables. * Résout l'URL canonique de l'application depuis les variables d'environnement.
* Priority: NEXT_PUBLIC_URL_DEV (development) → NEXT_PUBLIC_URL (production). * Priorité : NEXT_PUBLIC_URL_DEV (développement) → NEXT_PUBLIC_URL (production).
*/ */
function resolveAppUrl() { function resolveAppUrl() {
if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_URL_DEV) { 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 * Vérifie que les requêtes state-mutating proviennent de l'origine attendue.
* origin. GET, HEAD, and OPTIONS are exempt per RFC 7231. * GET, HEAD et OPTIONS sont exemptés (RFC 7231).
* @param {Request} request * @param {Request} request
* @returns {boolean} * @returns {boolean}
*/ */
@@ -110,7 +114,7 @@ function passesCsrfCheck(request) {
return origin === expectedOrigin; 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'); const referer = request.headers.get('referer');
if (referer) { if (referer) {
try { 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; 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: * Supporte :
* - Exact segments: '/health' * - Segments exacts : '/health'
* - Named params: '/users/:id' * - Paramètres nommés : '/users/:id'
* - Greedy wildcard (end only): '/storage/**' * - Wildcard greedy (fin uniquement) : '/storage/**'
* *
* @param {string} pattern - Route pattern * @param {string} pattern
* @param {string} path - Actual request path (e.g. '/users/42') * @param {string} path
* @returns {boolean} * @returns {boolean}
*/ */
function matchRoute(pattern, path) { 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} pattern - Ex. '/users/:id'
* @param {string} path - Actual path (e.g. '/users/42') * @param {string} path - Ex. '/users/42'
* @returns {Object} params — named params + optional `wildcard` string * @returns {Object} params — paramètres nommés + `wildcard` optionnel
*/ */
function extractPathParams(pattern, path) { function extractPathParams(pattern, path) {
const params = {}; const params = {};
@@ -196,37 +200,42 @@ function extractPathParams(pattern, path) {
// Main router // 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']); const SAFE_AUTH_MESSAGES = new Set(['Unauthorized', 'Admin access required']);
// Emitted at most once per process lifetime to avoid log flooding while still // Émis au plus une fois par lifetime de process pour éviter le log flooding.
// alerting operators that per-IP rate limiting is inactive.
let _rateLimitUnavailableWarned = false; 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 {Request} request - Requête Next.js entrante
* @param {string[]} path - Path segments after /zen/api/ * @param {string[]} path - Segments de chemin après /zen/api/
* @returns {Promise<Object>} Response payload (serialised to JSON by route-handler.js) * @returns {Promise<Object>} Payload de réponse (sérialisé en JSON par route-handler.js)
*/ */
export async function routeRequest(request, path) { export async function routeRequest(request, path) {
const method = request.method; const method = request.method;
const pathString = '/' + path.join('/'); const pathString = '/' + path.join('/');
// IP-based rate limit for all API calls. The health endpoint is exempt so // Fusion de toutes les routes — core en premier pour que les built-ins aient priorité.
// that monitoring probes do not consume quota. // Le rate limit est différé après le matching pour pouvoir honorer skipRateLimit
const isHealthCheck = path[0] === 'health' && method === 'GET'; // sans hardcoder de chemins dans le router.
if (!isHealthCheck) { 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); const ip = getIpFromRequest(request);
if (ip === 'unknown') { if (ip === 'unknown') {
// Client IP cannot be resolved — applying rate limiting against the // L'IP client ne peut pas être résolue — appliquer le rate limit sur la clé
// shared 'unknown' key would collapse every user's traffic into one // 'unknown' partagée effondrerait tout le trafic dans un seul bucket, permettant
// bucket, allowing a single attacker to exhaust it and deny service to // à un seul attaquant d'épuiser le quota et de dénier le service à tous les autres.
// all other users (global DoS). Rate limiting is therefore suspended // Le rate limiting est donc suspendu jusqu'à ce qu'un reverse proxy de confiance
// until a trusted reverse proxy is configured. // soit configuré avec ZEN_TRUST_PROXY=true.
// Operators must set ZEN_TRUST_PROXY=true once a verified proxy
// (Nginx, Cloudflare, AWS ALB, …) strips and rewrites forwarding headers.
if (!_rateLimitUnavailableWarned) { if (!_rateLimitUnavailableWarned) {
_rateLimitUnavailableWarned = true; _rateLimitUnavailableWarned = true;
fail( 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)) { if (!passesCsrfCheck(request)) {
return apiError('Forbidden', 'CSRF validation failed'); return apiError('Forbidden', 'CSRF validation failed');
} }
// Merge all routes — core first so built-ins take precedence over modules. if (!matchedRoute) {
const allRoutes = [...getCoreRoutes(), ...getAllApiRoutes()]; // Aucune route matchée — message générique sans refléter la méthode ou le chemin
// pour éviter l'énumération de routes.
for (const route of allRoutes) { return apiError('Not Found', 'The requested resource does not exist');
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.');
}
} }
// No route matched — return a generic message without reflecting the method // Enforcement auth depuis la définition de route, avant d'appeler le handler.
// or path back to the caller to avoid route enumeration. const context = {};
return apiError('Not Found', 'The requested resource does not exist'); try {
} if (matchedRoute.auth === 'admin') {
context.session = await requireAdmin();
// --------------------------------------------------------------------------- } else if (matchedRoute.auth === 'user') {
// HTTP status mapping context.session = await requireAuth();
// ---------------------------------------------------------------------------
/**
* 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;
} }
// '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 * - Unknown paths → denied
*/ */
import { validateSession } from '../../features/auth/lib/session.js';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { getSessionCookieName } from '../../shared/lib/appConfig.js'; import { getSessionCookieName } from '../../shared/lib/appConfig.js';
import { getSessionResolver } from '../api/router.js';
import { getAllStoragePublicPrefixes } from '@zen/core/modules/storage'; import { getAllStoragePublicPrefixes } from '@zen/core/modules/storage';
import { getFile } from '@zen/core/storage'; import { getFile } from '@zen/core/storage';
import { fail } from '../../shared/lib/logger.js'; 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'); return apiError('Unauthorized', 'Authentication required to access files');
} }
const session = await validateSession(sessionToken); const session = await getSessionResolver()(sessionToken);
if (!session) { if (!session) {
return apiError('Unauthorized', 'Invalid or expired 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 { fail } from '../../../shared/lib/logger.js';
import { cookies, headers } from 'next/headers'; import { cookies, headers } from 'next/headers';
import { getSessionCookieName, getPublicBaseUrl } from '../../../shared/lib/appConfig.js'; 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. * Get the client IP from the current server action context.
@@ -21,10 +34,49 @@ async function getClientIp() {
return getIpFromHeaders(h); 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. * Validate anti-bot fields submitted with forms.
* - _hp : honeypot field — must be empty * - _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 * @param {FormData} formData
* @returns {{ valid: boolean, error?: string }} * @returns {{ valid: boolean, error?: string }}
@@ -35,8 +87,13 @@ function validateAntiBotFields(formData) {
return { valid: false, error: 'Requête invalide' }; return { valid: false, error: 'Requête invalide' };
} }
const t = parseInt(formData.get('_t') || '0', 10); const MIN_ELAPSED_MS = 1_500;
if (t === 0 || Date.now() - t < 1500) { 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' }; 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 }; if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp(); const ip = await getClientIp();
const rl = checkRateLimit(ip, 'register'); const rl = enforceRateLimit(ip, 'register');
if (!rl.allowed) { if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` }; 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 user: result.user
}; };
} catch (error) { } catch (error) {
return { // Never return raw error.message to the client — library and database errors
success: false, // (e.g. unique-constraint violations) expose internal table names and schema.
error: error.message 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 }; if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp(); const ip = await getClientIp();
const rl = checkRateLimit(ip, 'login'); const rl = enforceRateLimit(ip, 'login');
if (!rl.allowed) { if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` }; 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. // JavaScript-accessible response body.
}; };
} catch (error) { } catch (error) {
return { fail(`Auth: loginAction error: ${error.message}`);
success: false, return { success: false, error: 'Identifiants invalides ou erreur interne. Veuillez réessayer.' };
error: error.message
};
} }
} }
/** /**
* Set session cookie (called by client after showing success message) * Set session cookie after verifying the token is a genuine live session.
* @param {string} token - Session token *
* 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 * @returns {Promise<Object>} Result object
*/ */
export async function setSessionCookie(token) { export async function setSessionCookie(token) {
try { 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(); const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, { cookieStore.set(COOKIE_NAME, token, {
httpOnly: true, httpOnly: true,
@@ -150,20 +221,32 @@ export async function setSessionCookie(token) {
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { fail(`Auth: setSessionCookie error: ${error.message}`);
success: false, return { success: false, error: 'Une erreur interne est survenue' };
error: error.message
};
} }
} }
/** /**
* 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 * @param {string} token - Session token
* @returns {Promise<Object>} Result object * @returns {Promise<Object>} Result object
*/ */
export async function refreshSessionCookie(token) { export async function refreshSessionCookie(token) {
try { 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(); const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, { cookieStore.set(COOKIE_NAME, token, {
httpOnly: true, httpOnly: true,
@@ -175,10 +258,8 @@ export async function refreshSessionCookie(token) {
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
return { fail(`Auth: refreshSessionCookie error: ${error.message}`);
success: false, return { success: false, error: 'Une erreur interne est survenue' };
error: error.message
};
} }
} }
@@ -202,10 +283,8 @@ export async function logoutAction() {
message: 'Déconnexion réussie' message: 'Déconnexion réussie'
}; };
} catch (error) { } catch (error) {
return { fail(`Auth: logoutAction error: ${error.message}`);
success: false, return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
error: error.message
};
} }
} }
@@ -245,8 +324,8 @@ export async function forgotPasswordAction(formData) {
if (!botCheck.valid) return { success: false, error: botCheck.error }; if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp(); const ip = await getClientIp();
const rl = checkRateLimit(ip, 'forgot_password'); const rl = enforceRateLimit(ip, 'forgot_password');
if (!rl.allowed) { if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` }; 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.' message: 'Si un compte existe avec cet e-mail, vous recevrez un lien pour réinitialiser votre mot de passe.'
}; };
} catch (error) { } catch (error) {
return { fail(`Auth: forgotPasswordAction error: ${error.message}`);
success: false, return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
error: error.message
};
} }
} }
@@ -278,8 +355,8 @@ export async function forgotPasswordAction(formData) {
export async function resetPasswordAction(formData) { export async function resetPasswordAction(formData) {
try { try {
const ip = await getClientIp(); const ip = await getClientIp();
const rl = checkRateLimit(ip, 'reset_password'); const rl = enforceRateLimit(ip, 'reset_password');
if (!rl.allowed) { if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` }; 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 token = formData.get('token');
const newPassword = formData.get('newPassword'); 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); const isValid = await verifyResetToken(email, token);
if (!isValid) { 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 }); 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.' message: 'Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.'
}; };
} catch (error) { } catch (error) {
return { if (error instanceof UserFacingError) {
success: false, return { success: false, error: error.message };
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) { export async function verifyEmailAction(formData) {
try { try {
const ip = await getClientIp(); const ip = await getClientIp();
const rl = checkRateLimit(ip, 'verify_email'); const rl = enforceRateLimit(ip, 'verify_email');
if (!rl.allowed) { if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` }; return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
} }
const email = formData.get('email'); const email = formData.get('email');
const token = formData.get('token'); 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); const isValid = await verifyEmailToken(email, token);
if (!isValid) { 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 // Find user and verify
@@ -334,7 +414,7 @@ export async function verifyEmailAction(formData) {
const user = await findOne('zen_auth_users', { email }); const user = await findOne('zen_auth_users', { email });
if (!user) { if (!user) {
throw new Error('Utilisateur introuvable'); throw new UserFacingError('Utilisateur introuvable');
} }
await verifyUserEmail(user.id); 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.' message: 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.'
}; };
} catch (error) { } catch (error) {
return { if (error instanceof UserFacingError) {
success: false, return { success: false, error: error.message };
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 { 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'; import { step, done, warn, fail } from './logger.js';
// Use globalThis to persist initialization flag across module reloads // Use globalThis to persist initialization flag across module reloads
@@ -64,7 +67,13 @@ export async function initializeZen(config = {}) {
}; };
try { 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(); result.discovery = await discoverModules();
const enabledCount = result.discovery.enabled?.length || 0; 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(', ')}`); 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) { if (externalModules.length > 0) {
result.external = await registerExternalModules(externalModules); 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) { if (!skipCron) {
result.cron = await startModuleCronJobs(); result.cron = await startModuleCronJobs();
@@ -119,5 +128,9 @@ export function resetZenInitialization() {
// Cron system not available // Cron system not available
} }
// Clear router config and feature routes so they are re-registered on next initializeZen()
clearRouterConfig();
clearFeatureRoutes();
warn('ZEN: initialization reset'); warn('ZEN: initialization reset');
} }
@@ -122,7 +122,10 @@ export function getIpFromHeaders(headersList) {
const realIp = headersList.get('x-real-ip')?.trim(); const realIp = headersList.get('x-real-ip')?.trim();
if (realIp && isValidIp(realIp)) return realIp; 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. // Configure ZEN_TRUST_PROXY=true behind a verified reverse proxy.
return 'unknown'; return 'unknown';
} }