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:
+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');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* In-memory rate limiter
|
||||
* Stores counters in a Map — resets on server restart, no DB required.
|
||||
*/
|
||||
|
||||
// Persist the store on globalThis so that module-cache invalidation (e.g. during
|
||||
// Next.js hot reload) does not silently reset all counters within the same process.
|
||||
// CRITICAL LIMITATION: this Map is process-local. In serverless or multi-worker
|
||||
// deployments every instance maintains its own store and rate limits do not
|
||||
// distribute across instances. For production deployments with multiple workers
|
||||
// replace this Map with a shared atomic store (e.g. Redis / Upstash).
|
||||
const STORE_KEY = Symbol.for('__ZEN_RATE_LIMIT_STORE__');
|
||||
if (!globalThis[STORE_KEY]) globalThis[STORE_KEY] = new Map();
|
||||
/** @type {Map<string, { count: number, windowStart: number, windowMs: number, blockedUntil: number|null }>} */
|
||||
const store = globalThis[STORE_KEY];
|
||||
|
||||
// Purge expired entries every 10 minutes to avoid memory leak
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store.entries()) {
|
||||
const windowExpired = now > entry.windowStart + entry.windowMs;
|
||||
const blockExpired = !entry.blockedUntil || now > entry.blockedUntil;
|
||||
if (windowExpired && blockExpired) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}, 10 * 60 * 1000);
|
||||
|
||||
// Allow garbage collection in test/serverless environments
|
||||
if (cleanup.unref) cleanup.unref();
|
||||
|
||||
/**
|
||||
* Rate limit presets per action.
|
||||
* maxAttempts : number of requests allowed in the window
|
||||
* windowMs : rolling window duration
|
||||
* blockMs : how long to block once the limit is exceeded
|
||||
*/
|
||||
export const RATE_LIMITS = {
|
||||
login: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 },
|
||||
register: { maxAttempts: 5, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 },
|
||||
forgot_password: { maxAttempts: 3, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 },
|
||||
reset_password: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 },
|
||||
verify_email: { maxAttempts: 10, windowMs: 60 * 60 * 1000, blockMs: 30 * 60 * 1000 },
|
||||
api: { maxAttempts: 120, windowMs: 60 * 1000, blockMs: 5 * 60 * 1000 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a given identifier is allowed for an action, and record the attempt.
|
||||
*
|
||||
* @param {string} identifier - IP address or user ID
|
||||
* @param {string} action - Key from RATE_LIMITS (e.g. 'login')
|
||||
* @returns {{ allowed: boolean, retryAfterMs?: number, remaining?: number }}
|
||||
*/
|
||||
export function checkRateLimit(identifier, action) {
|
||||
const config = RATE_LIMITS[action];
|
||||
if (!config) return { allowed: true };
|
||||
|
||||
const key = `${action}:${identifier}`;
|
||||
const now = Date.now();
|
||||
let entry = store.get(key);
|
||||
|
||||
// Still blocked
|
||||
if (entry?.blockedUntil && now < entry.blockedUntil) {
|
||||
return { allowed: false, retryAfterMs: entry.blockedUntil - now };
|
||||
}
|
||||
|
||||
// Start a fresh window (first request, or previous window has expired)
|
||||
if (!entry || now > entry.windowStart + entry.windowMs) {
|
||||
store.set(key, { count: 1, windowStart: now, windowMs: config.windowMs, blockedUntil: null });
|
||||
return { allowed: true, remaining: config.maxAttempts - 1 };
|
||||
}
|
||||
|
||||
// Increment counter in the current window
|
||||
entry.count += 1;
|
||||
|
||||
if (entry.count > config.maxAttempts) {
|
||||
entry.blockedUntil = now + config.blockMs;
|
||||
store.set(key, entry);
|
||||
return { allowed: false, retryAfterMs: config.blockMs };
|
||||
}
|
||||
|
||||
store.set(key, entry);
|
||||
return { allowed: true, remaining: config.maxAttempts - entry.count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true only when the string resembles a valid IPv4 or IPv6 address.
|
||||
* This prevents arbitrary attacker-supplied strings from being used as
|
||||
* rate-limit identifiers (which could allow bucket manipulation).
|
||||
* @param {string|null|undefined} ip
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidIp(ip) {
|
||||
if (!ip || typeof ip !== 'string') return false;
|
||||
// IPv4 — four decimal octets, each 0-255
|
||||
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
|
||||
return ip.split('.').every(octet => parseInt(octet, 10) <= 255);
|
||||
}
|
||||
// IPv6 — simplified structural check (colons + hex groups)
|
||||
return /^[0-9a-fA-F:]+$/.test(ip) && ip.includes(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the client IP from Next.js headers() (server actions).
|
||||
*
|
||||
* X-Forwarded-For and X-Real-IP are only trusted when ZEN_TRUST_PROXY=true is
|
||||
* explicitly set, confirming a trusted reverse proxy populates those headers.
|
||||
* Without this flag the headers are fully attacker-controlled and MUST NOT be
|
||||
* used as rate-limit keys — an attacker would trivially rotate identifiers.
|
||||
*
|
||||
* Set ZEN_TRUST_PROXY=true only when a verified reverse proxy (e.g. Nginx,
|
||||
* Cloudflare, AWS ALB) strips and rewrites forwarded headers before they reach
|
||||
* this application.
|
||||
*
|
||||
* @param {import('next/headers').ReadonlyHeaders} headersList
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getIpFromHeaders(headersList) {
|
||||
if (process.env.ZEN_TRUST_PROXY === 'true') {
|
||||
const forwarded = headersList.get('x-forwarded-for')?.split(',')[0]?.trim();
|
||||
if (forwarded && isValidIp(forwarded)) return forwarded;
|
||||
const realIp = headersList.get('x-real-ip')?.trim();
|
||||
if (realIp && isValidIp(realIp)) return realIp;
|
||||
}
|
||||
// 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the client IP from a Next.js Request object (API routes).
|
||||
* See getIpFromHeaders for the full trust-proxy rationale.
|
||||
* @param {Request} request
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getIpFromRequest(request) {
|
||||
if (process.env.ZEN_TRUST_PROXY === 'true') {
|
||||
const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
||||
if (forwarded && isValidIp(forwarded)) return forwarded;
|
||||
const realIp = request.headers.get('x-real-ip')?.trim();
|
||||
if (realIp && isValidIp(realIp)) return realIp;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a block duration in human-readable French.
|
||||
* @param {number} ms
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatRetryAfter(ms) {
|
||||
const seconds = Math.ceil(ms / 1000);
|
||||
if (seconds < 60) return `${seconds} secondes`;
|
||||
const minutes = Math.ceil(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''}`;
|
||||
const hours = Math.ceil(minutes / 60);
|
||||
return `${hours} heure${hours > 1 ? 's' : ''}`;
|
||||
}
|
||||
Reference in New Issue
Block a user