diff --git a/src/core/api/README.md b/src/core/api/README.md
index 11bf8a7..fadab97 100644
--- a/src/core/api/README.md
+++ b/src/core/api/README.md
@@ -10,10 +10,12 @@ Ce répertoire est un **framework d'API générique**. Il ne connaît aucune fea
src/core/api/
├── index.js Exports publics (routeRequest, requireAuth, apiSuccess, defineApiRoutes…)
├── router.js Orchestration : rate limit, CSRF, auth, dispatch
+├── runtime.js État global persisté : resolver de session + registre des feature routes
├── route-handler.js Intégration Next.js App Router (GET/POST/PUT/DELETE/PATCH)
├── define.js defineApiRoutes() — validateur de définitions de routes
-├── respond.js apiSuccess() / apiError() — utilitaires de réponse
+├── respond.js apiSuccess() / apiError() / getStatusCode() — utilitaires de réponse
├── core-routes.js Index des routes built-in (seul fichier à toucher pour un nouveau handler core)
+├── file-response.js Réponse streaming pour les fichiers (GET /zen/api/storage/**)
└── health.js GET /zen/api/health
src/features/auth/api.js Routes /zen/api/users/* (vivent avec la feature auth)
@@ -54,9 +56,11 @@ L'auth est **toujours déclarée dans la définition de route**, jamais dans le
---
-## Ajouter des routes à une feature existante
+## Ajouter des routes à une feature core
-Créer un fichier `api.js` dans le répertoire de la feature et exporter `routes` :
+Deux étapes :
+
+**1. Créer un fichier `api.js` dans le répertoire de la feature :**
```js
// src/features/myfeature/api.js
@@ -79,23 +83,19 @@ export const routes = defineApiRoutes([
]);
```
-Puis enregistrer dans `core-routes.js` (la seule ligne à ajouter) :
+**2. L'enregistrer dans `initializeZen()` (src/shared/lib/init.js) :**
```js
+import { registerFeatureRoutes } from '../../core/api/index.js';
import { routes as settingsRoutes } from '../../features/myfeature/api.js';
-export function getCoreRoutes() {
- return [
- ...healthRoutes,
- ...usersRoutes,
- ...storageRoutes,
- ...settingsRoutes, // ← ajout
- ];
-}
+registerFeatureRoutes(settingsRoutes);
```
**C'est tout.** Les routes sont disponibles à `GET /zen/api/settings` et `PUT /zen/api/settings`.
+> Note : `core-routes.js` n'est utilisé que pour les routes built-in de l'infrastructure (health, storage). Les routes de features passent par `registerFeatureRoutes()` dans `initializeZen()`.
+
---
## Ajouter des routes depuis un module
diff --git a/src/core/api/core-routes.js b/src/core/api/core-routes.js
index 3086db3..20c6d4e 100644
--- a/src/core/api/core-routes.js
+++ b/src/core/api/core-routes.js
@@ -1,29 +1,31 @@
/**
* Core Route Index
*
- * This is the ONLY file to edit when adding a new built-in handler.
- * Each feature manages its own route definitions via defineApiRoutes().
- * Do NOT put route logic here — only import and spread.
+ * Contains only routes that are part of the core API infrastructure itself:
+ * the health check and the storage file-serving endpoint. Both live under
+ * src/core/ and have no feature-level dependencies.
*
- * To add a new built-in handler:
- * 1. Create the handler in its natural location (e.g. src/features/myfeature/api.js)
+ * Feature routes (e.g. /users/*) are registered separately by each feature
+ * during initializeZen() via registerFeatureRoutes(). Module routes are
+ * collected via getAllApiRoutes() from the module registry.
+ *
+ * To add a new core infrastructure handler:
+ * 1. Create the handler in its natural location under src/core/
* 2. Export `routes` from it using defineApiRoutes()
* 3. Add one line here: import + spread
* 4. Done — never touch router.js
*/
import { routes as healthRoutes } from './health.js';
-import { routes as usersRoutes } from '../../features/auth/api.js';
import { routes as storageRoutes } from '../storage/api.js';
/**
- * Return all registered core API routes.
+ * Return all registered core infrastructure API routes.
* @returns {ReadonlyArray}
*/
export function getCoreRoutes() {
return [
...healthRoutes,
- ...usersRoutes,
...storageRoutes,
];
}
diff --git a/src/core/api/define.js b/src/core/api/define.js
index 3581dbe..0dff974 100644
--- a/src/core/api/define.js
+++ b/src/core/api/define.js
@@ -13,10 +13,16 @@
* ]);
*
* Required fields per route:
- * path {string} Must start with '/'. Supports ':param' and trailing '/**'.
- * method {string} One of: GET | POST | PUT | PATCH | DELETE
- * handler {Function} Async function — signature: (request, params, context)
- * auth {string} One of: 'public' | 'user' | 'admin'
+ * path {string} Must start with '/'. Supports ':param' and trailing '/**'.
+ * method {string} One of: GET | POST | PUT | PATCH | DELETE
+ * handler {Function} Async function — signature: (request, params, context)
+ * auth {string} One of: 'public' | 'user' | 'admin'
+ *
+ * Optional fields per route:
+ * skipRateLimit {boolean} When true, the router skips the per-IP rate limit
+ * check for this route. Use sparingly — only for routes
+ * that must remain accessible under high probe frequency
+ * (e.g. health checks from monitoring systems).
*
* Auth levels:
* 'public' Anyone can call this route. context.session is undefined.
@@ -66,6 +72,11 @@ export function defineApiRoutes(routes) {
`${at} (${route.method} ${route.path}) — "auth" must be one of "public" | "user" | "admin", got: ${JSON.stringify(route.auth)}`
);
}
+ if (route.skipRateLimit !== undefined && typeof route.skipRateLimit !== 'boolean') {
+ throw new TypeError(
+ `${at} (${route.method} ${route.path}) — "skipRateLimit" must be a boolean when provided, got: ${JSON.stringify(route.skipRateLimit)}`
+ );
+ }
}
// Freeze to prevent accidental mutation of route definitions at runtime.
diff --git a/src/core/api/file-response.js b/src/core/api/file-response.js
new file mode 100644
index 0000000..c0e52e5
--- /dev/null
+++ b/src/core/api/file-response.js
@@ -0,0 +1,57 @@
+/**
+ * File Response Builder
+ *
+ * Builds a NextResponse for streaming a file from storage.
+ * Encapsulates all file-serving semantics — MIME type rendering policy,
+ * Content-Disposition rules, security headers — in one place so the
+ * generic route handler stays agnostic about storage concerns.
+ *
+ * Policy:
+ * - Image MIME types are served inline (required for
tags).
+ * - All other types force a download to prevent in-browser rendering
+ * of potentially dangerous content.
+ * - Content-Disposition is always emitted explicitly; omitting it leaves
+ * rendering decisions to browser heuristics, which vary by content-type
+ * and browser version.
+ */
+
+import { NextResponse } from 'next/server';
+
+// MIME types that are safe to render inline (used in
tags).
+// All other types are forced to download.
+const INLINE_MIME_TYPES = new Set([
+ 'image/jpeg', 'image/png', 'image/gif', 'image/webp',
+]);
+
+/**
+ * Build a NextResponse that streams a file from a storage result envelope.
+ *
+ * @param {{ body: *, contentType?: string, contentLength?: number, lastModified?: Date, filename?: string }} file
+ * @returns {NextResponse}
+ */
+export function buildFileResponse(file) {
+ const contentType = file.contentType || 'application/octet-stream';
+
+ const headers = {
+ 'Content-Type': contentType,
+ 'Cache-Control': 'private, max-age=3600',
+ 'Last-Modified': file.lastModified?.toUTCString() ?? new Date().toUTCString(),
+ 'X-Content-Type-Options': 'nosniff',
+ 'X-Frame-Options': 'DENY',
+ };
+
+ if (file.contentLength != null) {
+ headers['Content-Length'] = String(file.contentLength);
+ }
+
+ if (INLINE_MIME_TYPES.has(contentType)) {
+ headers['Content-Disposition'] = 'inline';
+ } else if (file.filename) {
+ const encoded = encodeURIComponent(file.filename);
+ headers['Content-Disposition'] = `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`;
+ } else {
+ headers['Content-Disposition'] = 'attachment';
+ }
+
+ return new NextResponse(file.body, { status: 200, headers });
+}
diff --git a/src/core/api/health.js b/src/core/api/health.js
index 7e65e9c..289adc4 100644
--- a/src/core/api/health.js
+++ b/src/core/api/health.js
@@ -16,5 +16,5 @@ async function handleHealth() {
}
export const routes = defineApiRoutes([
- { path: '/health', method: 'GET', handler: handleHealth, auth: 'public' }
+ { path: '/health', method: 'GET', handler: handleHealth, auth: 'public', skipRateLimit: true }
]);
diff --git a/src/core/api/index.js b/src/core/api/index.js
index d4f87b9..d5c20df 100644
--- a/src/core/api/index.js
+++ b/src/core/api/index.js
@@ -2,14 +2,17 @@
* Zen API — Public Surface
*
* Exports the router entry point, auth helpers, response utilities,
- * and the route definition helper for use across the application.
+ * the route definition helper, and the feature routes registry.
*/
// Router
-export { routeRequest, getStatusCode, requireAuth, requireAdmin } from './router.js';
+export { routeRequest, requireAuth, requireAdmin } from './router.js';
+
+// Runtime state — session resolver + feature routes registry
+export { configureRouter, getSessionResolver, clearRouterConfig, registerFeatureRoutes, getFeatureRoutes, clearFeatureRoutes } from './runtime.js';
// Response utilities — use in all handlers (core and modules)
-export { apiSuccess, apiError } from './respond.js';
+export { apiSuccess, apiError, getStatusCode } from './respond.js';
// Route definition helper — use in handler files and module api.js files
export { defineApiRoutes } from './define.js';
diff --git a/src/core/api/respond.js b/src/core/api/respond.js
index f5a14d6..b4a4466 100644
--- a/src/core/api/respond.js
+++ b/src/core/api/respond.js
@@ -29,9 +29,8 @@ export function apiSuccess(payload) {
/**
* Create an error API response payload.
*
- * The `code` field is read by getStatusCode() in router.js to derive the
- * HTTP status. Always use one of the recognised codes below — any other
- * value maps to 500.
+ * The `code` field is read by getStatusCode() to derive the HTTP status.
+ * Always use one of the recognised codes below — any other value maps to 500.
*
* Valid codes → HTTP status:
* 'Unauthorized' → 401
@@ -49,3 +48,31 @@ export function apiSuccess(payload) {
export function apiError(code, message) {
return { error: code, message };
}
+
+/**
+ * Derive an HTTP status code from a response payload.
+ * Reads the `error` field set by apiError().
+ *
+ * @param {Object} response
+ * @returns {number}
+ */
+export function getStatusCode(response) {
+ if (response.error) {
+ switch (response.error) {
+ case 'Unauthorized':
+ return 401;
+ case 'Forbidden':
+ case 'Admin access required':
+ return 403;
+ case 'Not Found':
+ return 404;
+ case 'Bad Request':
+ return 400;
+ case 'Too Many Requests':
+ return 429;
+ default:
+ return 500;
+ }
+ }
+ return 200;
+}
diff --git a/src/core/api/route-handler.js b/src/core/api/route-handler.js
index 89fad44..b2daf9e 100644
--- a/src/core/api/route-handler.js
+++ b/src/core/api/route-handler.js
@@ -1,184 +1,51 @@
/**
* ZEN API Route Handler
- *
- * This is the main catch-all route handler for the ZEN API under /zen/api/.
- * It should be used in a Next.js App Router route at: app/zen/api/[...path]/route.js
+ *
+ * Catch-all Next.js App Router handler for all routes under /zen/api/.
+ * Place this file at: app/zen/api/[...path]/route.js
+ *
+ * All HTTP methods are handled by a single factory. GET additionally
+ * supports file streaming responses from the storage endpoint.
*/
import { NextResponse } from 'next/server';
-import { routeRequest, getStatusCode } from './router.js';
+import { routeRequest } from './router.js';
+import { apiError, getStatusCode } from './respond.js';
+import { buildFileResponse } from './file-response.js';
import { fail } from '../../shared/lib/logger.js';
-/**
- * Handle GET requests
- */
-export async function GET(request, { params }) {
- try {
- const resolvedParams = await params;
- const path = resolvedParams.path || [];
- const response = await routeRequest(request, path);
-
- // Check if this is a file response (from storage endpoint)
- if (response.success && response.file) {
- const contentType = response.file.contentType || 'application/octet-stream';
- const headers = {
- 'Content-Type': contentType,
- 'Content-Length': response.file.contentLength?.toString() || '',
- 'Cache-Control': 'private, max-age=3600',
- 'Last-Modified': response.file.lastModified?.toUTCString() || new Date().toUTCString(),
- 'X-Content-Type-Options': 'nosniff',
- 'X-Frame-Options': 'DENY',
- };
+const GENERIC_ERROR_MSG = 'An unexpected error occurred. Please try again later.';
- // Always emit an explicit Content-Disposition header — omitting it leaves
- // rendering decisions to browser heuristics, which varies by content-type
- // and browser version. Image MIME types are served inline (required for
- //
tags); every other type forces a download to prevent in-browser
- // rendering of potentially dangerous content.
- const INLINE_MIME_TYPES = new Set([
- 'image/jpeg', 'image/png', 'image/gif', 'image/webp',
- ]);
- if (INLINE_MIME_TYPES.has(contentType)) {
- headers['Content-Disposition'] = 'inline';
- } else if (response.file.filename) {
- const encoded = encodeURIComponent(response.file.filename);
- headers['Content-Disposition'] = `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`;
- } else {
- headers['Content-Disposition'] = 'attachment';
+/**
+ * Create a Next.js route handler for a given HTTP method.
+ *
+ * @param {boolean} [serveFiles=false] - When true, file streaming responses are
+ * returned directly instead of being wrapped in JSON. Only GET needs this.
+ * @returns {Function} Next.js App Router handler
+ */
+function makeHandler(serveFiles = false) {
+ return async function handler(request, { params }) {
+ try {
+ const path = (await params).path ?? [];
+ const response = await routeRequest(request, path);
+
+ if (serveFiles && response.success && response.file) {
+ return buildFileResponse(response.file);
}
- return new NextResponse(response.file.body, { status: 200, headers });
+ return NextResponse.json(response, { status: getStatusCode(response) });
+ } catch (error) {
+ fail(`API error: ${error.message}`);
+ return NextResponse.json(
+ apiError('Internal Server Error', GENERIC_ERROR_MSG),
+ { status: 500 }
+ );
}
-
- // Regular JSON response
- const statusCode = getStatusCode(response);
- return NextResponse.json(response, {
- status: statusCode,
- headers: {
- 'Content-Type': 'application/json'
- }
- });
- } catch (error) {
- fail(`API error: ${error.message}`);
- return NextResponse.json(
- {
- error: 'Internal Server Error',
- message: 'An unexpected error occurred. Please try again later.'
- },
- { status: 500 }
- );
- }
-}
-
-/**
- * Handle POST requests
- */
-export async function POST(request, { params }) {
- try {
- const resolvedParams = await params;
- const path = resolvedParams.path || [];
- const response = await routeRequest(request, path);
- const statusCode = getStatusCode(response);
-
- return NextResponse.json(response, {
- status: statusCode,
- headers: {
- 'Content-Type': 'application/json'
- }
- });
- } catch (error) {
- fail(`API error: ${error.message}`);
- return NextResponse.json(
- {
- error: 'Internal Server Error',
- message: 'An unexpected error occurred. Please try again later.'
- },
- { status: 500 }
- );
- }
-}
-
-/**
- * Handle PUT requests
- */
-export async function PUT(request, { params }) {
- try {
- const resolvedParams = await params;
- const path = resolvedParams.path || [];
- const response = await routeRequest(request, path);
- const statusCode = getStatusCode(response);
-
- return NextResponse.json(response, {
- status: statusCode,
- headers: {
- 'Content-Type': 'application/json'
- }
- });
- } catch (error) {
- fail(`API error: ${error.message}`);
- return NextResponse.json(
- {
- error: 'Internal Server Error',
- message: 'An unexpected error occurred. Please try again later.'
- },
- { status: 500 }
- );
- }
-}
-
-/**
- * Handle DELETE requests
- */
-export async function DELETE(request, { params }) {
- try {
- const resolvedParams = await params;
- const path = resolvedParams.path || [];
- const response = await routeRequest(request, path);
- const statusCode = getStatusCode(response);
-
- return NextResponse.json(response, {
- status: statusCode,
- headers: {
- 'Content-Type': 'application/json'
- }
- });
- } catch (error) {
- fail(`API error: ${error.message}`);
- return NextResponse.json(
- {
- error: 'Internal Server Error',
- message: 'An unexpected error occurred. Please try again later.'
- },
- { status: 500 }
- );
- }
-}
-
-/**
- * Handle PATCH requests
- */
-export async function PATCH(request, { params }) {
- try {
- const resolvedParams = await params;
- const path = resolvedParams.path || [];
- const response = await routeRequest(request, path);
- const statusCode = getStatusCode(response);
-
- return NextResponse.json(response, {
- status: statusCode,
- headers: {
- 'Content-Type': 'application/json'
- }
- });
- } catch (error) {
- fail(`API error: ${error.message}`);
- return NextResponse.json(
- {
- error: 'Internal Server Error',
- message: 'An unexpected error occurred. Please try again later.'
- },
- { status: 500 }
- );
- }
+ };
}
+export const GET = makeHandler(true);
+export const POST = makeHandler();
+export const PUT = makeHandler();
+export const PATCH = makeHandler();
+export const DELETE = makeHandler();
diff --git a/src/core/api/router.js b/src/core/api/router.js
index 0ed0377..5b0bfff 100644
--- a/src/core/api/router.js
+++ b/src/core/api/router.js
@@ -1,28 +1,34 @@
/**
* API Router
*
- * Generic request router — has no knowledge of specific features.
- * Core handlers and modules self-register their routes; this file
- * only orchestrates rate limiting, CSRF, auth enforcement, and dispatch.
+ * Orchestre rate limiting, CSRF, enforcement d'auth et dispatch vers les handlers.
+ * N'a aucune connaissance des features spécifiques — les routes s'auto-enregistrent.
*
- * Request lifecycle:
+ * Initialisation requise :
+ * Appeler configureRouter({ resolveSession }) une fois au démarrage (dans initializeZen)
+ * avant toute requête. Le resolver est fourni par la feature auth, ce qui garde
+ * core/api/ libre de tout import feature.
+ *
+ * Cycle de vie d'une requête :
* route-handler.js → routeRequest()
- * → rate limit check (health GET exempt)
- * → CSRF origin validation (state-mutating methods only)
- * → unified route match (core routes first, then module routes)
- * → auth enforcement from route definition
+ * → rate limit (routes skipRateLimit exemptées)
+ * → validation CSRF (méthodes state-mutating uniquement)
+ * → matching sur toutes les routes (core en premier, puis features, puis modules)
+ * → enforcement auth depuis la définition de route
* → handler(request, params, context)
*/
-import { validateSession } from '../../features/auth/lib/session.js';
import { cookies } from 'next/headers';
import { getSessionCookieName } from '../../shared/lib/appConfig.js';
import { getAllApiRoutes } from '../modules/index.js';
-import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../features/auth/lib/rateLimit.js';
+import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../shared/lib/rateLimit.js';
import { fail } from '../../shared/lib/logger.js';
import { getCoreRoutes } from './core-routes.js';
+import { getFeatureRoutes, getSessionResolver } from './runtime.js';
import { apiError } from './respond.js';
+export { configureRouter, getSessionResolver, clearRouterConfig } from './runtime.js';
+
const COOKIE_NAME = getSessionCookieName();
// ---------------------------------------------------------------------------
@@ -30,11 +36,10 @@ const COOKIE_NAME = getSessionCookieName();
// ---------------------------------------------------------------------------
/**
- * Require a valid session. Throws if the request carries no valid cookie.
- * @param {Request} request
+ * Exige une session valide. Lève une erreur si aucun cookie valide n'est présent.
* @returns {Promise