diff --git a/.env.example b/.env.example index ea63b06..feabf4f 100644 --- a/.env.example +++ b/.env.example @@ -15,15 +15,26 @@ ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev ZEN_DB_SSL_DISABLED=false -# STORAGE (S3-compatible — Cloudflare R2 ou Backblaze B2) -# R2 : ZEN_STORAGE_ENDPOINT=.r2.cloudflarestorage.com ZEN_STORAGE_REGION=auto -# B2 : ZEN_STORAGE_ENDPOINT=s3..backblazeb2.com ZEN_STORAGE_REGION= +# STORAGE +# Fournisseur : 'r2' (défaut) ou 'backblaze' +ZEN_STORAGE_PROVIDER=r2 + +# Cloudflare R2 (ZEN_STORAGE_PROVIDER=r2) +# Endpoint format : .r2.cloudflarestorage.com ZEN_STORAGE_ENDPOINT= ZEN_STORAGE_REGION=auto ZEN_STORAGE_BUCKET= ZEN_STORAGE_ACCESS_KEY= ZEN_STORAGE_SECRET_KEY= +# Backblaze B2 (ZEN_STORAGE_PROVIDER=backblaze) +# Endpoint format : s3..backblazeb2.com +ZEN_STORAGE_B2_ENDPOINT= +ZEN_STORAGE_B2_REGION= +ZEN_STORAGE_B2_BUCKET= +ZEN_STORAGE_B2_ACCESS_KEY= +ZEN_STORAGE_B2_SECRET_KEY= + # EMAIL ZEN_EMAIL_RESEND_APIKEY= ZEN_EMAIL_FROM_NAME="EXEMPLE" diff --git a/src/core/api/core-routes.js b/src/core/api/core-routes.js index 20c6d4e..5540791 100644 --- a/src/core/api/core-routes.js +++ b/src/core/api/core-routes.js @@ -6,8 +6,7 @@ * src/core/ and have no feature-level dependencies. * * 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. + * during initializeZen() via registerFeatureRoutes(). * * To add a new core infrastructure handler: * 1. Create the handler in its natural location under src/core/ diff --git a/src/core/api/router.js b/src/core/api/router.js index 5b0bfff..31f4fe1 100644 --- a/src/core/api/router.js +++ b/src/core/api/router.js @@ -20,7 +20,6 @@ import { cookies } from 'next/headers'; import { getSessionCookieName } from '../../shared/lib/appConfig.js'; -import { getAllApiRoutes } from '../modules/index.js'; import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../shared/lib/rateLimit.js'; import { fail } from '../../shared/lib/logger.js'; import { getCoreRoutes } from './core-routes.js'; @@ -220,7 +219,7 @@ export async function routeRequest(request, path) { // 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 allRoutes = [...getCoreRoutes(), ...getFeatureRoutes()]; const matchedRoute = allRoutes.find( route => matchRoute(route.path, pathString) && route.method === method diff --git a/src/core/storage/api.js b/src/core/storage/api.js index f457d28..f0aa644 100644 --- a/src/core/storage/api.js +++ b/src/core/storage/api.js @@ -7,30 +7,27 @@ * because access policy depends on the file path, not a single role. * The handler enforces its own rules: * - Public prefix paths → no session required - * - User files → session required; users can only access their own files - * - Organisation files → admin session required - * - Post files (private) → admin session required + * - All other paths → session required; access governed by registered policies * - Unknown paths → denied + * + * Call configureStorageApi({ getPublicPrefixes, getAccessPolicies }) during + * initializeZen before the first request, following the same pattern as + * configureRouter in core/api/runtime.js. */ import { cookies } from 'next/headers'; import { getSessionCookieName } from '../../shared/lib/appConfig.js'; import { getSessionResolver } from '../api/router.js'; -import { getAllStoragePublicPrefixes, getAllStorageAccessPolicies } from '@zen/core/modules/storage'; -import { getFile } from '@zen/core/storage'; +import { getFile } from './index.js'; import { fail } from '../../shared/lib/logger.js'; import { defineApiRoutes } from '../api/define.js'; import { apiError } from '../api/respond.js'; +import { getStoragePublicPrefixes, getStorageAccessPolicies } from './storage-config.js'; const COOKIE_NAME = getSessionCookieName(); -/** - * Serve a file from storage with path-based security validation. - * - * @param {Request} request - * @param {{ wildcard: string }} params - wildcard contains the full file key - * @returns {Promise<{ success: true, file: Object }|{ error: string, message: string }>} - */ +// ─── Handlers ───────────────────────────────────────────────────────────────── + async function handleGetFile(_request, { wildcard: fileKey }) { try { if (!fileKey) { @@ -52,8 +49,8 @@ async function handleGetFile(_request, { wildcard: fileKey }) { // Files whose path starts with a declared prefix are served without authentication. // The path must have at least two segments beyond the prefix // ({...prefix}/{id}/{filename}) to prevent unintentional root-level exposure. - const publicPrefixes = getAllStoragePublicPrefixes(); - const matchedPrefix = publicPrefixes.find(prefix => fileKey.startsWith(prefix + '/')); + const publicPrefixes = getStoragePublicPrefixes(); + const matchedPrefix = publicPrefixes.find(prefix => fileKey.startsWith(prefix + '/')); if (matchedPrefix) { const prefixDepth = matchedPrefix.split('/').length; if (pathParts.length < prefixDepth + 2) { @@ -63,7 +60,7 @@ async function handleGetFile(_request, { wildcard: fileKey }) { } // Require authentication for all other paths. - const cookieStore = await cookies(); + const cookieStore = await cookies(); const sessionToken = cookieStore.get(COOKIE_NAME)?.value; if (!sessionToken) { @@ -77,8 +74,8 @@ async function handleGetFile(_request, { wildcard: fileKey }) { } // Path-based access control driven by policies declared in each module. - const policies = getAllStorageAccessPolicies(); - const policy = policies.find(p => pathParts[0] === p.prefix); + const policies = getStorageAccessPolicies(); + const policy = policies.find(p => pathParts[0] === p.prefix); if (!policy) { return apiError('Forbidden', 'Invalid file path'); @@ -103,10 +100,6 @@ async function handleGetFile(_request, { wildcard: fileKey }) { } } -/** - * Retrieve a file from the storage backend and return the response envelope. - * @param {string} fileKey - */ async function fetchFile(fileKey) { const result = await getFile(fileKey); @@ -122,11 +115,11 @@ async function fetchFile(fileKey) { return { success: true, file: { - body: result.data.body, - contentType: result.data.contentType, + body: result.data.body, + contentType: result.data.contentType, contentLength: result.data.contentLength, - lastModified: result.data.lastModified - } + lastModified: result.data.lastModified, + }, }; } diff --git a/src/core/storage/backblaze.js b/src/core/storage/backblaze.js new file mode 100644 index 0000000..9cf6d60 --- /dev/null +++ b/src/core/storage/backblaze.js @@ -0,0 +1,28 @@ +/** + * Backblaze B2 provider config (S3-compatible API). + * Reads ZEN_STORAGE_B2_* environment variables. + * + * Endpoint format: s3..backblazeb2.com (e.g. s3.us-west-004.backblazeb2.com) + */ + +export function getConfig() { + const host = process.env.ZEN_STORAGE_B2_ENDPOINT; + const region = process.env.ZEN_STORAGE_B2_REGION; + const accessKeyId = process.env.ZEN_STORAGE_B2_ACCESS_KEY; + const secretAccessKey = process.env.ZEN_STORAGE_B2_SECRET_KEY; + const bucket = process.env.ZEN_STORAGE_B2_BUCKET; + + if (!host || !accessKeyId || !secretAccessKey) { + throw new Error( + 'Backblaze B2 credentials are not configured. Please set ZEN_STORAGE_B2_ENDPOINT, ZEN_STORAGE_B2_ACCESS_KEY, and ZEN_STORAGE_B2_SECRET_KEY.' + ); + } + if (!bucket) { + throw new Error('ZEN_STORAGE_B2_BUCKET environment variable is not set.'); + } + if (!region) { + throw new Error('ZEN_STORAGE_B2_REGION environment variable is not set.'); + } + + return { accessKeyId, secretAccessKey, bucket, host, region }; +} diff --git a/src/core/storage/cloudflare-r2.js b/src/core/storage/cloudflare-r2.js new file mode 100644 index 0000000..4be93f8 --- /dev/null +++ b/src/core/storage/cloudflare-r2.js @@ -0,0 +1,23 @@ +/** + * Cloudflare R2 provider config. + * Reads ZEN_STORAGE_* environment variables. + */ + +export function getConfig() { + const host = process.env.ZEN_STORAGE_ENDPOINT; + const region = process.env.ZEN_STORAGE_REGION ?? 'auto'; + const accessKeyId = process.env.ZEN_STORAGE_ACCESS_KEY; + const secretAccessKey = process.env.ZEN_STORAGE_SECRET_KEY; + const bucket = process.env.ZEN_STORAGE_BUCKET; + + if (!host || !accessKeyId || !secretAccessKey) { + throw new Error( + 'Storage credentials are not configured. Please set ZEN_STORAGE_ENDPOINT, ZEN_STORAGE_ACCESS_KEY, and ZEN_STORAGE_SECRET_KEY.' + ); + } + if (!bucket) { + throw new Error('ZEN_STORAGE_BUCKET environment variable is not set.'); + } + + return { accessKeyId, secretAccessKey, bucket, host, region }; +} diff --git a/src/core/storage/index.js b/src/core/storage/index.js index e304572..52b9d83 100644 --- a/src/core/storage/index.js +++ b/src/core/storage/index.js @@ -1,259 +1,33 @@ -/** - * Zen Storage Module - Cloudflare R2 - * Provides file upload, download, deletion, and management functionality - * Uses native fetch + crypto (AWS Signature V4) — no external dependencies - */ - -import { createHmac, createHash } from 'crypto'; +import { createHash } from 'crypto'; import { fail, warn, info } from '../../shared/lib/logger.js'; +import { + signRequest, + buildPresignedUrl, + toBuffer, + xmlFirst, + xmlAll, + sanitizeHeaderValue, + escapeXml, + metaToHeaders, + headersToMeta, + encodePath, +} from './signing.js'; -// ─── AWS Signature V4 ──────────────────────────────────────────────────────── +// ─── Provider selection ─────────────────────────────────────────────────────── -function sha256hex(data) { - return createHash('sha256').update(data).digest('hex'); +async function getConfig() { + const provider = process.env.ZEN_STORAGE_PROVIDER ?? 'r2'; + const { getConfig: providerGetConfig } = provider === 'backblaze' + ? await import('./backblaze.js') + : await import('./cloudflare-r2.js'); + return providerGetConfig(); } -function hmac(key, data) { - return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key)).update(data).digest(); -} +// ─── Storage functions ──────────────────────────────────────────────────────── -function hmacHex(key, data) { - return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key)).update(data).digest('hex'); -} - -function amzDate(date) { - return date.toISOString().replace(/[:\-]|\.\d{3}/g, ''); -} - -function dateStamp(date) { - return date.toISOString().slice(0, 10).replace(/-/g, ''); -} - -/** - * Encode a string per AWS Signature V4 spec (encodes everything except A-Z a-z 0-9 - _ . ~) - */ -function encodeS3(str) { - return encodeURIComponent(str) - .replace(/!/g, '%21') - .replace(/'/g, '%27') - .replace(/\(/g, '%28') - .replace(/\)/g, '%29') - .replace(/\*/g, '%2A'); -} - -/** - * Encode a URI path, encoding each segment individually (preserving slashes) - */ -function encodePath(path) { - return path - .split('/') - .map(segment => (segment ? encodeS3(segment) : '')) - .join('/'); -} - -function signingKey(secret, ds, region, service) { - const kDate = hmac('AWS4' + secret, ds); - const kRegion = hmac(kDate, region); - const kService = hmac(kRegion, service); - return hmac(kService, 'aws4_request'); -} - -/** - * Sign an S3 request using AWS Signature V4. - * Returns the full URL and the headers object to pass to fetch. - */ -function signRequest({ method, host, path, query = {}, extraHeaders = {}, bodyBuffer, config, date }) { - const { accessKeyId, secretAccessKey, region } = config; - const service = 's3'; - - const ts = amzDate(date); - const ds = dateStamp(date); - const bodyHash = sha256hex(bodyBuffer ?? Buffer.alloc(0)); - - const headers = { - host, - 'x-amz-date': ts, - 'x-amz-content-sha256': bodyHash, - ...extraHeaders, - }; - - const sortedHeaderKeys = Object.keys(headers).sort(); - const canonicalHeaders = sortedHeaderKeys.map(k => `${k}:${headers[k]}\n`).join(''); - const signedHeaders = sortedHeaderKeys.join(';'); - - const canonicalQueryString = Object.keys(query) - .sort() - .map(k => `${encodeS3(k)}=${encodeS3(query[k])}`) - .join('&'); - - const canonicalRequest = [ - method, - encodePath(path), - canonicalQueryString, - canonicalHeaders, - signedHeaders, - bodyHash, - ].join('\n'); - - const scope = `${ds}/${region}/${service}/aws4_request`; - const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n'); - - const sig = hmacHex(signingKey(secretAccessKey, ds, region, service), stringToSign); - const auth = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${sig}`; - - const requestHeaders = { ...headers, Authorization: auth }; - delete requestHeaders.host; - - const url = canonicalQueryString - ? `https://${host}${path}?${canonicalQueryString}` - : `https://${host}${path}`; - - return { url, headers: requestHeaders }; -} - -/** - * Build a presigned URL (signature embedded in query string, no Authorization header). - * The payload is marked UNSIGNED-PAYLOAD so no body hash is needed at signing time. - */ -function buildPresignedUrl({ method, host, path, expiresIn, config, date }) { - const { accessKeyId, secretAccessKey, region } = config; - const service = 's3'; - - const ts = amzDate(date); - const ds = dateStamp(date); - const scope = `${ds}/${region}/${service}/aws4_request`; - - const query = { - 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', - 'X-Amz-Credential': `${accessKeyId}/${scope}`, - 'X-Amz-Date': ts, - 'X-Amz-Expires': String(expiresIn), - 'X-Amz-SignedHeaders': 'host', - }; - - const canonicalQueryString = Object.keys(query) - .sort() - .map(k => `${encodeS3(k)}=${encodeS3(query[k])}`) - .join('&'); - - const canonicalRequest = [ - method, - encodePath(path), - canonicalQueryString, - `host:${host}\n`, - 'host', - 'UNSIGNED-PAYLOAD', - ].join('\n'); - - const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n'); - const sig = hmacHex(signingKey(secretAccessKey, ds, region, service), stringToSign); - - return `https://${host}${path}?${canonicalQueryString}&X-Amz-Signature=${sig}`; -} - -// ─── Config ────────────────────────────────────────────────────────────────── - -function getConfig() { - const host = process.env.ZEN_STORAGE_ENDPOINT; - const region = process.env.ZEN_STORAGE_REGION ?? 'auto'; - const accessKeyId = process.env.ZEN_STORAGE_ACCESS_KEY; - const secretAccessKey = process.env.ZEN_STORAGE_SECRET_KEY; - const bucket = process.env.ZEN_STORAGE_BUCKET; - - if (!host || !accessKeyId || !secretAccessKey) { - throw new Error( - 'Storage credentials are not configured. Please set ZEN_STORAGE_ENDPOINT, ZEN_STORAGE_ACCESS_KEY, and ZEN_STORAGE_SECRET_KEY.' - ); - } - if (!bucket) { - throw new Error('ZEN_STORAGE_BUCKET environment variable is not set'); - } - - return { accessKeyId, secretAccessKey, bucket, host, region }; -} - -// ─── Minimal XML helpers ───────────────────────────────────────────────────── - -function xmlFirst(xml, tag) { - const m = xml.match(new RegExp(`<${tag}[^>]*>(.*?)`, 's')); - return m ? m[1] : null; -} - -function xmlAll(xml, tag) { - const re = new RegExp(`<${tag}[^>]*>(.*?)`, 'gs'); - const results = []; - let m; - while ((m = re.exec(xml)) !== null) results.push(m[1]); - return results; -} - -// ─── Body normalizer ───────────────────────────────────────────────────────── - -async function toBuffer(body) { - if (Buffer.isBuffer(body)) return body; - if (body instanceof Uint8Array) return Buffer.from(body); - if (typeof body === 'string') return Buffer.from(body, 'utf8'); - if (body instanceof Blob) return Buffer.from(await body.arrayBuffer()); - return Buffer.from(body); -} - -// ─── Sanitization helpers ───────────────────────────────────────────────────── - -/** - * Strip HTTP header injection characters (\r, \n, \0) from a header value. - * A value containing these characters would break the canonical request format - * and could allow an attacker to inject arbitrary signed headers. - */ -function sanitizeHeaderValue(value) { - return String(value).replace(/[\r\n\0]/g, ''); -} - -/** - * Escape XML special characters to prevent injection into the DeleteObjects payload. - */ -function escapeXml(str) { - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -// ─── Metadata header helpers ───────────────────────────────────────────────── - -function metaToHeaders(metadata) { - return Object.fromEntries( - Object.entries(metadata).map(([k, v]) => [ - `x-amz-meta-${sanitizeHeaderValue(k).toLowerCase()}`, - sanitizeHeaderValue(v), - ]) - ); -} - -function headersToMeta(headers) { - return Object.fromEntries( - [...headers.entries()] - .filter(([k]) => k.startsWith('x-amz-meta-')) - .map(([k, v]) => [k.replace('x-amz-meta-', ''), v]) - ); -} - -// ─── Storage functions ─────────────────────────────────────────────────────── - -/** - * Upload a file to storage - * @param {Object} options - * @param {string} options.key - File path/key in the bucket - * @param {Buffer|string|Uint8Array|Blob} options.body - File content - * @param {string} options.contentType - MIME type - * @param {Object} options.metadata - Optional metadata key-value pairs - * @param {string} options.cacheControl - Optional cache control header - * @returns {Promise} - */ async function uploadFile({ key, body, contentType, metadata = {}, cacheControl }) { try { - const config = getConfig(); + const config = await getConfig(); const path = `/${config.bucket}/${key}`; const date = new Date(); const bodyBuffer = await toBuffer(body); @@ -264,16 +38,7 @@ async function uploadFile({ key, body, contentType, metadata = {}, cacheControl ...(cacheControl && { 'cache-control': sanitizeHeaderValue(cacheControl) }), }; - const { url, headers } = signRequest({ - method: 'PUT', - host: config.host, - path, - extraHeaders, - bodyBuffer, - config, - date, - }); - + const { url, headers } = signRequest({ method: 'PUT', host: config.host, path, extraHeaders, bodyBuffer, config, date }); const response = await fetch(url, { method: 'PUT', headers, body: bodyBuffer }); if (!response.ok) { @@ -288,27 +53,13 @@ async function uploadFile({ key, body, contentType, metadata = {}, cacheControl } } -/** - * Upload an image with optimized settings - * @param {Object} options - * @param {string} options.key - File path/key in the bucket - * @param {Buffer|Blob} options.body - Image content - * @param {string} options.contentType - Image MIME type - * @param {Object} options.metadata - Optional metadata - * @returns {Promise} - */ async function uploadImage({ key, body, contentType, metadata = {} }) { return uploadFile({ key, body, contentType, metadata, cacheControl: 'public, max-age=31536000' }); } -/** - * Delete a file from storage - * @param {string} key - File path/key to delete - * @returns {Promise} - */ async function deleteFile(key) { try { - const config = getConfig(); + const config = await getConfig(); const path = `/${config.bucket}/${key}`; const date = new Date(); @@ -327,14 +78,9 @@ async function deleteFile(key) { } } -/** - * Delete multiple files from storage - * @param {string[]} keys - Array of file paths/keys to delete - * @returns {Promise} - */ async function deleteFiles(keys) { try { - const config = getConfig(); + const config = await getConfig(); const path = `/${config.bucket}`; const date = new Date(); @@ -365,9 +111,9 @@ async function deleteFiles(keys) { const xml = await response.text(); const deleted = xmlAll(xml, 'Deleted').map(b => ({ Key: xmlFirst(b, 'Key') })); - const errors = xmlAll(xml, 'Error').map(b => ({ - Key: xmlFirst(b, 'Key'), - Code: xmlFirst(b, 'Code'), + const errors = xmlAll(xml, 'Error').map(b => ({ + Key: xmlFirst(b, 'Key'), + Code: xmlFirst(b, 'Code'), Message: xmlFirst(b, 'Message'), })); @@ -378,14 +124,9 @@ async function deleteFiles(keys) { } } -/** - * Get a file from storage - * @param {string} key - File path/key to retrieve - * @returns {Promise} File data with metadata - */ async function getFile(key) { try { - const config = getConfig(); + const config = await getConfig(); const path = `/${config.bucket}/${key}`; const date = new Date(); @@ -404,9 +145,9 @@ async function getFile(key) { data: { key, body: buffer, - contentType: response.headers.get('content-type'), + contentType: response.headers.get('content-type'), contentLength: Number(response.headers.get('content-length')), - lastModified: response.headers.get('last-modified') + lastModified: response.headers.get('last-modified') ? new Date(response.headers.get('last-modified')) : null, metadata: headersToMeta(response.headers), @@ -419,14 +160,9 @@ async function getFile(key) { } } -/** - * Get file metadata without downloading the file - * @param {string} key - File path/key - * @returns {Promise} File metadata - */ async function getFileMetadata(key) { try { - const config = getConfig(); + const config = await getConfig(); const path = `/${config.bucket}/${key}`; const date = new Date(); @@ -441,13 +177,13 @@ async function getFileMetadata(key) { success: true, data: { key, - contentType: response.headers.get('content-type'), + contentType: response.headers.get('content-type'), contentLength: Number(response.headers.get('content-length')), - lastModified: response.headers.get('last-modified') + lastModified: response.headers.get('last-modified') ? new Date(response.headers.get('last-modified')) : null, metadata: headersToMeta(response.headers), - etag: response.headers.get('etag'), + etag: response.headers.get('etag'), }, error: null, }; @@ -457,27 +193,14 @@ async function getFileMetadata(key) { } } -/** - * Check if a file exists in storage - * @param {string} key - File path/key to check - * @returns {Promise} - */ async function fileExists(key) { const result = await getFileMetadata(key); return result.success; } -/** - * List files in a directory/prefix - * @param {Object} options - * @param {string} options.prefix - Directory prefix (e.g., 'users/123/') - * @param {number} options.maxKeys - Maximum number of keys to return (default: 1000) - * @param {string} options.continuationToken - Token for pagination - * @returns {Promise} - */ async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {}) { try { - const config = getConfig(); + const config = await getConfig(); const path = `/${config.bucket}`; const date = new Date(); @@ -486,9 +209,9 @@ async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {} const query = { 'list-type': '2', - 'max-keys': String(validMaxKeys), - ...(prefix && { prefix }), - ...(continuationToken && { 'continuation-token': continuationToken }), + 'max-keys': String(validMaxKeys), + ...(prefix && { prefix }), + ...(continuationToken && { 'continuation-token': continuationToken }), }; const { url, headers } = signRequest({ method: 'GET', host: config.host, path, query, config, date }); @@ -500,44 +223,31 @@ async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {} } const xml = await response.text(); - const isTruncated = xmlFirst(xml, 'IsTruncated') === 'true'; + const isTruncated = xmlFirst(xml, 'IsTruncated') === 'true'; const nextContinuationToken = xmlFirst(xml, 'NextContinuationToken'); const files = xmlAll(xml, 'Contents').map(block => ({ - key: xmlFirst(block, 'Key'), - size: parseInt(xmlFirst(block, 'Size') || '0', 10), + key: xmlFirst(block, 'Key'), + size: parseInt(xmlFirst(block, 'Size') || '0', 10), lastModified: xmlFirst(block, 'LastModified') ? new Date(xmlFirst(block, 'LastModified')) : null, - etag: (xmlFirst(block, 'ETag') || '').replace(/"/g, ''), + etag: (xmlFirst(block, 'ETag') || '').replace(/"/g, ''), })); - return { - success: true, - data: { files, isTruncated, nextContinuationToken, count: files.length }, - error: null, - }; + return { success: true, data: { files, isTruncated, nextContinuationToken, count: files.length }, error: null }; } catch (error) { fail(`Storage list files failed: ${error.message}`); return { success: false, data: null, error: error.message }; } } -/** - * Generate a presigned URL for temporary access to a file - * @param {Object} options - * @param {string} options.key - File path/key - * @param {number} options.expiresIn - URL expiration time in seconds (default: 3600) - * @param {string} options.operation - 'get' or 'put' (default: 'get') - * @returns {Promise} - */ async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) { try { - const config = getConfig(); - const path = `/${config.bucket}/${key}`; - const date = new Date(); + const config = await getConfig(); + const path = `/${config.bucket}/${key}`; + const date = new Date(); const method = operation === 'put' ? 'PUT' : 'GET'; // R2/S3 max presigned URL lifetime is 7 days (604800 seconds) const validExpiresIn = Math.min(Math.max(Math.floor(Number(expiresIn)), 1), 604800); - const url = buildPresignedUrl({ method, host: config.host, path, expiresIn: validExpiresIn, config, date }); return { success: true, data: { key, url, expiresIn: validExpiresIn, operation }, error: null }; @@ -547,19 +257,11 @@ async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) { } } -/** - * Copy a file within the same bucket using the native S3 CopyObject API. - * No data is transferred through the server — the provider copies server-side. - * @param {Object} options - * @param {string} options.sourceKey - Source file path/key - * @param {string} options.destinationKey - Destination file path/key - * @returns {Promise} - */ async function copyFile({ sourceKey, destinationKey }) { try { - const config = getConfig(); - const path = `/${config.bucket}/${destinationKey}`; - const date = new Date(); + const config = await getConfig(); + const path = `/${config.bucket}/${destinationKey}`; + const date = new Date(); const { url, headers } = signRequest({ method: 'PUT', @@ -585,36 +287,20 @@ async function copyFile({ sourceKey, destinationKey }) { } } -/** - * Proxy a file from storage, returning a handler-ready response object. - * Use this instead of presigned URLs to avoid exposing storage URLs to clients. - * The returned object is consumed directly by the API router to stream the file. - * @param {string} key - File path/key to retrieve - * @param {Object} options - * @param {string} [options.filename] - Optional download filename (Content-Disposition) - * @returns {Promise} - */ async function proxyFile(key, { filename } = {}) { const result = await getFile(key); if (!result.success) return { success: false, error: result.error }; return { success: true, file: { - body: result.data.body, - contentType: result.data.contentType, + body: result.data.body, + contentType: result.data.contentType, contentLength: result.data.contentLength, ...(filename && { filename }), }, }; } -/** - * Move a file (copy + delete source) - * @param {Object} options - * @param {string} options.sourceKey - Source file path/key - * @param {string} options.destinationKey - Destination file path/key - * @returns {Promise} - */ async function moveFile({ sourceKey, destinationKey }) { try { const copyResult = await copyFile({ sourceKey, destinationKey }); @@ -634,7 +320,8 @@ async function moveFile({ sourceKey, destinationKey }) { } } -// Export utility functions +// ─── Exports ────────────────────────────────────────────────────────────────── + export { generateUniqueFilename, getFileExtension, @@ -648,7 +335,6 @@ export { FILE_SIZE_LIMITS, } from './utils.js'; -// Export storage functions export { uploadFile, uploadImage, @@ -663,3 +349,5 @@ export { copyFile, moveFile, }; + +export { configureStorageApi } from './storage-config.js'; diff --git a/src/core/storage/signing.js b/src/core/storage/signing.js new file mode 100644 index 0000000..1b1d859 --- /dev/null +++ b/src/core/storage/signing.js @@ -0,0 +1,198 @@ +/** + * AWS Signature V4 — internal helpers for S3-compatible storage providers. + * Not exported from package.json; only imported by provider implementations. + */ + +import { createHmac, createHash } from 'crypto'; + +// ─── Crypto primitives ─────────────────────────────────────────────────────── + +export function sha256hex(data) { + return createHash('sha256').update(data).digest('hex'); +} + +/** + * HMAC-SHA256. Pass encoding='hex' for a hex string; omit for a Buffer. + */ +export function hmac(key, data, encoding) { + return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key)) + .update(data) + .digest(encoding); +} + +export function amzDate(date) { + return date.toISOString().replace(/[:\-]|\.\d{3}/g, ''); +} + +export function dateStamp(date) { + return date.toISOString().slice(0, 10).replace(/-/g, ''); +} + +// ─── URI encoding ──────────────────────────────────────────────────────────── + +/** + * Encode a string per AWS Signature V4 spec (encodes everything except A-Z a-z 0-9 - _ . ~) + */ +export function encodeS3(str) { + return encodeURIComponent(str) + .replace(/!/g, '%21') + .replace(/'/g, '%27') + .replace(/\(/g, '%28') + .replace(/\)/g, '%29') + .replace(/\*/g, '%2A'); +} + +/** + * Encode a URI path, encoding each segment individually (preserving slashes) + */ +export function encodePath(path) { + return path.split('/').map(seg => (seg ? encodeS3(seg) : '')).join('/'); +} + +// ─── Signing key ───────────────────────────────────────────────────────────── + +function signingKey(secret, ds, region, service) { + const kDate = hmac('AWS4' + secret, ds); + const kRegion = hmac(kDate, region); + const kService = hmac(kRegion, service); + return hmac(kService, 'aws4_request'); +} + +// ─── Request signing ───────────────────────────────────────────────────────── + +/** + * Sign an S3 request using AWS Signature V4. + * Returns the full URL and the headers object to pass to fetch. + */ +export function signRequest({ method, host, path, query = {}, extraHeaders = {}, bodyBuffer, config, date }) { + const { accessKeyId, secretAccessKey, region } = config; + const service = 's3'; + + const ts = amzDate(date); + const ds = dateStamp(date); + const bodyHash = sha256hex(bodyBuffer ?? Buffer.alloc(0)); + + const headers = { host, 'x-amz-date': ts, 'x-amz-content-sha256': bodyHash, ...extraHeaders }; + + const sortedKeys = Object.keys(headers).sort(); + const canonicalHeaders = sortedKeys.map(k => `${k}:${headers[k]}\n`).join(''); + const signedHeaders = sortedKeys.join(';'); + + const canonicalQueryString = Object.keys(query) + .sort() + .map(k => `${encodeS3(k)}=${encodeS3(query[k])}`) + .join('&'); + + const canonicalRequest = [method, encodePath(path), canonicalQueryString, canonicalHeaders, signedHeaders, bodyHash].join('\n'); + + const scope = `${ds}/${region}/${service}/aws4_request`; + const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n'); + const sig = hmac(signingKey(secretAccessKey, ds, region, service), stringToSign, 'hex'); + const auth = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${sig}`; + + const requestHeaders = { ...headers, Authorization: auth }; + delete requestHeaders.host; + + const url = canonicalQueryString + ? `https://${host}${path}?${canonicalQueryString}` + : `https://${host}${path}`; + + return { url, headers: requestHeaders }; +} + +/** + * Build a presigned URL (signature embedded in query string, no Authorization header). + * The payload is marked UNSIGNED-PAYLOAD so no body hash is needed at signing time. + */ +export function buildPresignedUrl({ method, host, path, expiresIn, config, date }) { + const { accessKeyId, secretAccessKey, region } = config; + const service = 's3'; + + const ts = amzDate(date); + const ds = dateStamp(date); + const scope = `${ds}/${region}/${service}/aws4_request`; + + const query = { + 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', + 'X-Amz-Credential': `${accessKeyId}/${scope}`, + 'X-Amz-Date': ts, + 'X-Amz-Expires': String(expiresIn), + 'X-Amz-SignedHeaders': 'host', + }; + + const canonicalQueryString = Object.keys(query) + .sort() + .map(k => `${encodeS3(k)}=${encodeS3(query[k])}`) + .join('&'); + + const canonicalRequest = [method, encodePath(path), canonicalQueryString, `host:${host}\n`, 'host', 'UNSIGNED-PAYLOAD'].join('\n'); + const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n'); + const sig = hmac(signingKey(secretAccessKey, ds, region, service), stringToSign, 'hex'); + + return `https://${host}${path}?${canonicalQueryString}&X-Amz-Signature=${sig}`; +} + +// ─── XML helpers ───────────────────────────────────────────────────────────── + +export function xmlFirst(xml, tag) { + const m = xml.match(new RegExp(`<${tag}[^>]*>(.*?)`, 's')); + return m ? m[1] : null; +} + +export function xmlAll(xml, tag) { + const re = new RegExp(`<${tag}[^>]*>(.*?)`, 'gs'); + const results = []; + let m; + while ((m = re.exec(xml)) !== null) results.push(m[1]); + return results; +} + +// ─── Body normalizer ───────────────────────────────────────────────────────── + +export async function toBuffer(body) { + if (Buffer.isBuffer(body)) return body; + if (body instanceof Uint8Array) return Buffer.from(body); + if (typeof body === 'string') return Buffer.from(body, 'utf8'); + if (body instanceof Blob) return Buffer.from(await body.arrayBuffer()); + return Buffer.from(body); +} + +// ─── Security helpers ───────────────────────────────────────────────────────── + +/** + * Strip HTTP header injection characters (\r, \n, \0) from a header value. + */ +export function sanitizeHeaderValue(value) { + return String(value).replace(/[\r\n\0]/g, ''); +} + +/** + * Escape XML special characters to prevent injection into the DeleteObjects payload. + */ +export function escapeXml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// ─── Metadata helpers ───────────────────────────────────────────────────────── + +export function metaToHeaders(metadata) { + return Object.fromEntries( + Object.entries(metadata).map(([k, v]) => [ + `x-amz-meta-${sanitizeHeaderValue(k).toLowerCase()}`, + sanitizeHeaderValue(v), + ]) + ); +} + +export function headersToMeta(headers) { + return Object.fromEntries( + [...headers.entries()] + .filter(([k]) => k.startsWith('x-amz-meta-')) + .map(([k, v]) => [k.replace('x-amz-meta-', ''), v]) + ); +} diff --git a/src/core/storage/storage-config.js b/src/core/storage/storage-config.js new file mode 100644 index 0000000..aaf1ebe --- /dev/null +++ b/src/core/storage/storage-config.js @@ -0,0 +1,21 @@ +/** + * Storage API runtime configuration. + * Holds injected prefix/policy resolvers — same pattern as core/api/runtime.js. + * Imported by both api.js (reads) and index.js (exports configureStorageApi). + */ + +let _getPublicPrefixes = () => []; +let _getAccessPolicies = () => []; + +export function configureStorageApi({ getPublicPrefixes, getAccessPolicies }) { + _getPublicPrefixes = getPublicPrefixes; + _getAccessPolicies = getAccessPolicies; +} + +export function getStoragePublicPrefixes() { + return _getPublicPrefixes(); +} + +export function getStorageAccessPolicies() { + return _getAccessPolicies(); +} diff --git a/src/features/admin/actions.js b/src/features/admin/actions.js index d3e8d20..e8117dd 100644 --- a/src/features/admin/actions.js +++ b/src/features/admin/actions.js @@ -1,12 +1,10 @@ /** * Admin Server Actions - * - * These are exported separately from admin/index.js to avoid bundling + * + * Exported separately from admin/index.js to avoid bundling * server-side code (which includes database imports) into client components. - * - * Usage: - * import { getDashboardStats, getModuleDashboardStats } from '@zen/core/admin/actions'; + * + * Usage: import { getDashboardStats } from '@zen/core/admin/actions'; */ export { getDashboardStats } from './actions/statsActions.js'; -export { getAllModuleDashboardStats as getModuleDashboardStats } from '@zen/core/modules/actions'; diff --git a/src/features/admin/navigation.server.js b/src/features/admin/navigation.server.js index b51b5c3..991a1c2 100644 --- a/src/features/admin/navigation.server.js +++ b/src/features/admin/navigation.server.js @@ -15,20 +15,12 @@ * Icons are passed as string names and resolved on the client. */ -// Import from the main package to use the same registry as discovery -import { moduleSystem } from '@zen/core'; -const { getAllAdminNavigation } = moduleSystem; - /** - * Build complete navigation sections including modules - * This should ONLY be called on the server (in page.js) + * Build complete navigation sections * @param {string} pathname - Current pathname - * @param {Object} enabledModules - Object with module names as keys (for compatibility) * @returns {Array} Complete navigation sections (serializable, icons as strings) */ -export function buildNavigationSections(pathname, enabledModules = null) { - // Core navigation sections (always available) - // Use icon NAMES (strings) for serialization across server/client boundary +export function buildNavigationSections(pathname) { const coreNavigation = [ { id: 'Dashboard', @@ -45,10 +37,6 @@ export function buildNavigationSections(pathname, enabledModules = null) { } ]; - // Get module navigation from registry (only works on server) - const moduleNavigation = getAllAdminNavigation(pathname); - - // System navigation (always at the end) const systemNavigation = [ { id: 'users', @@ -65,5 +53,5 @@ export function buildNavigationSections(pathname, enabledModules = null) { } ]; - return [...coreNavigation, ...moduleNavigation, ...systemNavigation]; + return [...coreNavigation, ...systemNavigation]; } diff --git a/src/features/admin/page.js b/src/features/admin/page.js index 8163daa..560b90b 100644 --- a/src/features/admin/page.js +++ b/src/features/admin/page.js @@ -1,134 +1,75 @@ /** * Admin Page - Server Component Wrapper for Next.js App Router - * - * This is a complete server component that handles all admin routes. - * Users can simply re-export this in their app/admin/[...admin]/page.js: - * - * ```javascript + * + * Re-export this in your app/admin/[...admin]/page.js: * export { default } from '@zen/core/admin/page'; - * ``` - * - * This eliminates the need to manually import and pass all actions and props. */ import { AdminPagesLayout, AdminPagesClient } from '@zen/core/admin/pages'; import { protectAdmin } from '@zen/core/admin'; import { buildNavigationSections } from '@zen/core/admin/navigation'; -import { getDashboardStats, getModuleDashboardStats } from '@zen/core/admin/actions'; +import { getDashboardStats } from '@zen/core/admin/actions'; import { logoutAction } from '@zen/core/auth/actions'; -import { getAppName, getModulesConfig, getAppConfig, moduleSystem } from '@zen/core'; +import { getAppName } from '@zen/core'; -const { getAdminPage } = moduleSystem; - -/** - * Parse admin route params and build the module path - * Handles nested paths like /admin/invoice/clients/edit/123 - * - * @param {Object} params - Next.js route params - * @returns {Object} Parsed info with path, action, and id - */ function parseAdminRoute(params) { const parts = params?.admin || []; - + if (parts.length === 0) { - return { path: '/admin/dashboard', action: null, id: null, isCorePage: true }; + return { path: '/admin/dashboard', action: null, id: null }; } - - // Check for core pages first + const corePages = ['dashboard', 'users', 'profile']; if (corePages.includes(parts[0])) { - // Users: support /admin/users/edit/:id if (parts[0] === 'users' && parts[1] === 'edit' && parts[2]) { - return { path: '/admin/users', action: 'edit', id: parts[2], isCorePage: true }; + return { path: '/admin/users', action: 'edit', id: parts[2] }; } - return { path: `/admin/${parts[0]}`, action: null, id: null, isCorePage: true }; + return { path: `/admin/${parts[0]}`, action: null, id: null }; } - - // Build module path - // Look for 'new', 'create', or 'edit' to determine action - const actionKeywords = ['new', 'create', 'edit']; + let pathParts = []; let action = null; let id = null; - + const actionKeywords = ['new', 'create', 'edit']; + for (let i = 0; i < parts.length; i++) { const part = parts[i]; - if (actionKeywords.includes(part)) { action = part === 'create' ? 'new' : part; - // If it's 'edit', the next part is the ID if (action === 'edit' && i + 1 < parts.length) { id = parts[i + 1]; } break; } - pathParts.push(part); } - - // Build the full path - let fullPath = '/admin/' + pathParts.join('/'); - if (action) { - fullPath += '/' + action; - } - - return { path: fullPath, action, id, isCorePage: false }; -} -/** - * Check if a path is a module page - * @param {string} fullPath - Full admin path - * @returns {Object|null} Module info if it's a module page, null otherwise - */ -function getModulePageInfo(fullPath) { - const modulePage = getAdminPage(fullPath); - if (modulePage) { - return { - module: modulePage.module, - path: fullPath - }; - } - return null; + return { path: '/admin/' + pathParts.join('/') + (action ? '/' + action : ''), action, id }; } export default async function AdminPage({ params }) { const resolvedParams = await params; const session = await protectAdmin(); const appName = getAppName(); - const enabledModules = getModulesConfig(); - const config = getAppConfig(); - + const statsResult = await getDashboardStats(); const dashboardStats = statsResult.success ? statsResult.stats : null; - - // Fetch module dashboard stats for widgets - const moduleStats = await getModuleDashboardStats(); - - // Build navigation on server where module registry is available - const navigationSections = buildNavigationSections('/', enabledModules); - - // Parse route and build path - const { path, action, id, isCorePage } = parseAdminRoute(resolvedParams); - - // Check if this is a module page (just check existence, don't load) - const modulePageInfo = isCorePage ? null : getModulePageInfo(path); - + + const navigationSections = buildNavigationSections('/'); + const { path, action, id } = parseAdminRoute(resolvedParams); + return ( - ); diff --git a/src/index.js b/src/index.js index 8d3be77..34865fa 100644 --- a/src/index.js +++ b/src/index.js @@ -23,20 +23,11 @@ export * as stripe from "./core/payments/stripe.js"; // Export PDF utilities as namespace export * as pdf from "./core/pdf/index.js"; -// Export module system as namespace -export * as moduleSystem from "./core/modules/index.js"; - // NOTE: Toast components are CLIENT ONLY - import from '@zen/core/toast' // Do not export here to avoid mixing client/server boundaries -// Export modules system as namespace (legacy, includes invoice module) -export * as modules from "./modules/index.js"; - -// Export public pages (Zen routes) -export { PublicPagesLayout, PublicPagesClient } from "./modules/pages.js"; - // Export app configuration utilities -export { getAppName, getAppConfig, getSessionCookieName, getModulesConfig, getPublicBaseUrl } from "./shared/lib/appConfig.js"; +export { getAppName, getAppConfig, getSessionCookieName, getPublicBaseUrl } from "./shared/lib/appConfig.js"; // Export initialization utilities export { initializeZen, resetZenInitialization } from "./shared/lib/init.js"; diff --git a/src/shared/lib/appConfig.js b/src/shared/lib/appConfig.js index 3713cfa..71b1094 100644 --- a/src/shared/lib/appConfig.js +++ b/src/shared/lib/appConfig.js @@ -3,8 +3,6 @@ * Centralized configuration management for the entire package */ -import { getAvailableModules } from '../../modules/modules.registry.js'; - /** * Get application name from environment variables * @returns {string} Application name @@ -36,28 +34,6 @@ export function getPublicBaseUrl() { return String(raw).replace(/\/$/, ''); } -/** - * Get enabled modules configuration (server-side only) - * This function dynamically reads from modules.registry.js and checks environment variables - * Use this on the server and pass the result to client components as props - * - * To enable a module, set the environment variable: ZEN_MODULE_{NAME}=true - * Example: ZEN_MODULE_INVOICE=true - * - * @returns {Object} Object with module names as keys and boolean values - */ -export function getModulesConfig() { - const modules = {}; - const availableModules = getAvailableModules(); - - for (const moduleName of availableModules) { - const envVar = `ZEN_MODULE_${moduleName.toUpperCase()}`; - modules[moduleName] = process.env[envVar] === 'true'; - } - - return modules; -} - /** * Get application configuration * @returns {Object} Application configuration object @@ -68,10 +44,7 @@ export function getAppConfig() { sessionCookieName: getSessionCookieName(), timezone: process.env.ZEN_TIMEZONE || 'America/Toronto', dateFormat: process.env.ZEN_DATE_FORMAT || 'YYYY-MM-DD', - // Currency configuration (for currency module) defaultCurrency: process.env.ZEN_CURRENCY || 'CAD', currencySymbol: process.env.ZEN_CURRENCY_SYMBOL || '$', - // Enabled modules - modules: getModulesConfig(), }; } diff --git a/src/shared/lib/init.js b/src/shared/lib/init.js index 8e696ee..23ffdbe 100644 --- a/src/shared/lib/init.js +++ b/src/shared/lib/init.js @@ -1,136 +1,49 @@ /** * ZEN Initialization - * Initialize all ZEN services and modules using dynamic module discovery - */ - -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 -const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__'); - -/** - * Initialize ZEN system - * Discovers modules dynamically and starts cron jobs * - * Recommended: Use instrumentation.js for automatic initialization - * Alternative: Call this function manually in your root layout + * Wires core feature dependencies into the API router. + * This is the composition root — the only place that connects features to core. * * @example - * // instrumentation.js (Recommended) — internal modules only + * // instrumentation.js * export async function register() { * if (process.env.NEXT_RUNTIME === 'nodejs') { * const { initializeZen } = await import('@zen/core'); * await initializeZen(); * } * } - * - * @example - * // instrumentation.js — with external modules from zen.config.js - * import zenConfig from './zen.config.js'; - * export async function register() { - * if (process.env.NEXT_RUNTIME === 'nodejs') { - * const { initializeZen } = await import('@zen/core'); - * await initializeZen(zenConfig); - * } - * } - * - * @param {Object} config - Configuration object - * @param {Array} config.modules - External module configs (from zen.config.js) - * @param {boolean} config.skipCron - Skip cron job initialization - * @param {boolean} config.skipDb - Skip database initialization - * @returns {Promise} Initialization result */ -export async function initializeZen(config = {}) { - const { modules: externalModules = [], skipCron = false, skipDb = true } = config; - // Only run on server-side +import { configureRouter, registerFeatureRoutes, clearRouterConfig, clearFeatureRoutes } from '../../core/api/index.js'; +import { validateSession } from '../../features/auth/lib/session.js'; +import { routes as authRoutes } from '../../features/auth/api.js'; +import { done, warn } from './logger.js'; + +const ZEN_INIT_KEY = Symbol.for('__ZEN_INITIALIZED__'); + +export async function initializeZen() { if (typeof window !== 'undefined') { return { skipped: true, reason: 'client-side' }; } - // Prevent multiple initializations using globalThis if (globalThis[ZEN_INIT_KEY]) { warn('ZEN: already initialized, skipping'); return { skipped: true, reason: 'already-initialized' }; } globalThis[ZEN_INIT_KEY] = true; - step('ZEN starting...'); - const result = { - discovery: null, - cron: { started: [], errors: [] } - }; + configureRouter({ resolveSession: validateSession }); + registerFeatureRoutes(authRoutes); - try { - // 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); + done('ZEN: ready'); - // Step 2: Discover and register internal modules (from modules.registry.js) - result.discovery = await discoverModules(); - - const enabledCount = result.discovery.enabled?.length || 0; - const skippedCount = result.discovery.skipped?.length || 0; - - if (enabledCount > 0) { - done(`ZEN: ${enabledCount} module(s): ${result.discovery.enabled.join(', ')}`); - } - if (skippedCount > 0) { - warn(`ZEN: skipped ${skippedCount} module(s): ${result.discovery.skipped.join(', ')}`); - } - - // Step 3: Register external modules from zen.config.js (if any) - if (externalModules.length > 0) { - result.external = await registerExternalModules(externalModules); - - if (result.external.registered.length > 0) { - done(`ZEN: ${result.external.registered.length} external module(s): ${result.external.registered.join(', ')}`); - } - } - - // Step 4: Start cron jobs for all enabled modules (internal + external) - if (!skipCron) { - result.cron = await startModuleCronJobs(); - - if (result.cron.started.length > 0) { - done(`ZEN: ${result.cron.started.length} cron job(s): ${result.cron.started.join(', ')}`); - } - } - - done('ZEN: ready'); - - } catch (error) { - fail(`ZEN: init failed: ${error.message}`); - result.error = error.message; - } - - return result; + return {}; } -/** - * Reset initialization flag (useful for testing or manual reinitialization) - * @returns {void} - */ export function resetZenInitialization() { globalThis[ZEN_INIT_KEY] = false; - - // Stop all cron jobs using the module system - try { - stopModuleCronJobs(); - } catch (e) { - // Cron system not available - } - - // Clear router config and feature routes so they are re-registered on next initializeZen() clearRouterConfig(); clearFeatureRoutes(); - warn('ZEN: initialization reset'); }