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:
+12
-12
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 }
|
||||
]);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user