feat(storage): refactor storage config and remove module registry

Introduce a dedicated `storage-config.js` for registering public
prefixes and access policies via `configureStorageApi()`, replacing the
previous `getAllStoragePublicPrefixes` / `getAllStorageAccessPolicies`
imports from the module registry.

Remove `getAllApiRoutes()` from the router so module-level routes are no
longer auto-collected; feature routes must now be registered explicitly
via `registerFeatureRoutes()` during `initializeZen()`.

Update `.env.example` to document separate `ZEN_STORAGE_PROVIDER`,
`ZEN_STORAGE_B2_*` variables for Backblaze B2 alongside the existing
Cloudflare R2 variables, making provider selection explicit.

Clean up admin navigation and page components to drop module-injected
nav entries, keeping only core and system sections.
This commit is contained in:
2026-04-14 17:43:06 -04:00
parent 4a06cace5d
commit 242ea69664
15 changed files with 404 additions and 640 deletions
+14 -3
View File
@@ -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_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev
ZEN_DB_SSL_DISABLED=false ZEN_DB_SSL_DISABLED=false
# STORAGE (S3-compatible — Cloudflare R2 ou Backblaze B2) # STORAGE
# R2 : ZEN_STORAGE_ENDPOINT=<accountId>.r2.cloudflarestorage.com ZEN_STORAGE_REGION=auto # Fournisseur : 'r2' (défaut) ou 'backblaze'
# B2 : ZEN_STORAGE_ENDPOINT=s3.<region>.backblazeb2.com ZEN_STORAGE_REGION=<region> ZEN_STORAGE_PROVIDER=r2
# Cloudflare R2 (ZEN_STORAGE_PROVIDER=r2)
# Endpoint format : <accountId>.r2.cloudflarestorage.com
ZEN_STORAGE_ENDPOINT= ZEN_STORAGE_ENDPOINT=
ZEN_STORAGE_REGION=auto ZEN_STORAGE_REGION=auto
ZEN_STORAGE_BUCKET= ZEN_STORAGE_BUCKET=
ZEN_STORAGE_ACCESS_KEY= ZEN_STORAGE_ACCESS_KEY=
ZEN_STORAGE_SECRET_KEY= ZEN_STORAGE_SECRET_KEY=
# Backblaze B2 (ZEN_STORAGE_PROVIDER=backblaze)
# Endpoint format : s3.<region>.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 # EMAIL
ZEN_EMAIL_RESEND_APIKEY= ZEN_EMAIL_RESEND_APIKEY=
ZEN_EMAIL_FROM_NAME="EXEMPLE" ZEN_EMAIL_FROM_NAME="EXEMPLE"
+1 -2
View File
@@ -6,8 +6,7 @@
* src/core/ and have no feature-level dependencies. * src/core/ and have no feature-level dependencies.
* *
* Feature routes (e.g. /users/*) are registered separately by each feature * Feature routes (e.g. /users/*) are registered separately by each feature
* during initializeZen() via registerFeatureRoutes(). Module routes are * during initializeZen() via registerFeatureRoutes().
* collected via getAllApiRoutes() from the module registry.
* *
* To add a new core infrastructure handler: * To add a new core infrastructure handler:
* 1. Create the handler in its natural location under src/core/ * 1. Create the handler in its natural location under src/core/
+1 -2
View File
@@ -20,7 +20,6 @@
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { getSessionCookieName } from '../../shared/lib/appConfig.js'; import { getSessionCookieName } from '../../shared/lib/appConfig.js';
import { getAllApiRoutes } from '../modules/index.js';
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../shared/lib/rateLimit.js'; import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../shared/lib/rateLimit.js';
import { fail } from '../../shared/lib/logger.js'; import { fail } from '../../shared/lib/logger.js';
import { getCoreRoutes } from './core-routes.js'; import { getCoreRoutes } from './core-routes.js';
@@ -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é. // 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 // Le rate limit est différé après le matching pour pouvoir honorer skipRateLimit
// sans hardcoder de chemins dans le router. // sans hardcoder de chemins dans le router.
const allRoutes = [...getCoreRoutes(), ...getFeatureRoutes(), ...getAllApiRoutes()]; const allRoutes = [...getCoreRoutes(), ...getFeatureRoutes()];
const matchedRoute = allRoutes.find( const matchedRoute = allRoutes.find(
route => matchRoute(route.path, pathString) && route.method === method route => matchRoute(route.path, pathString) && route.method === method
+18 -25
View File
@@ -7,30 +7,27 @@
* because access policy depends on the file path, not a single role. * because access policy depends on the file path, not a single role.
* The handler enforces its own rules: * The handler enforces its own rules:
* - Public prefix paths → no session required * - Public prefix paths → no session required
* - User files → session required; users can only access their own files * - All other paths → session required; access governed by registered policies
* - Organisation files → admin session required
* - Post files (private) → admin session required
* - Unknown paths → denied * - 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 { cookies } from 'next/headers';
import { getSessionCookieName } from '../../shared/lib/appConfig.js'; import { getSessionCookieName } from '../../shared/lib/appConfig.js';
import { getSessionResolver } from '../api/router.js'; import { getSessionResolver } from '../api/router.js';
import { getAllStoragePublicPrefixes, getAllStorageAccessPolicies } from '@zen/core/modules/storage'; import { getFile } from './index.js';
import { getFile } from '@zen/core/storage';
import { fail } from '../../shared/lib/logger.js'; import { fail } from '../../shared/lib/logger.js';
import { defineApiRoutes } from '../api/define.js'; import { defineApiRoutes } from '../api/define.js';
import { apiError } from '../api/respond.js'; import { apiError } from '../api/respond.js';
import { getStoragePublicPrefixes, getStorageAccessPolicies } from './storage-config.js';
const COOKIE_NAME = getSessionCookieName(); const COOKIE_NAME = getSessionCookieName();
/** // ─── Handlers ─────────────────────────────────────────────────────────────────
* 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 }>}
*/
async function handleGetFile(_request, { wildcard: fileKey }) { async function handleGetFile(_request, { wildcard: fileKey }) {
try { try {
if (!fileKey) { if (!fileKey) {
@@ -52,8 +49,8 @@ async function handleGetFile(_request, { wildcard: fileKey }) {
// Files whose path starts with a declared prefix are served without authentication. // Files whose path starts with a declared prefix are served without authentication.
// The path must have at least two segments beyond the prefix // The path must have at least two segments beyond the prefix
// ({...prefix}/{id}/{filename}) to prevent unintentional root-level exposure. // ({...prefix}/{id}/{filename}) to prevent unintentional root-level exposure.
const publicPrefixes = getAllStoragePublicPrefixes(); const publicPrefixes = getStoragePublicPrefixes();
const matchedPrefix = publicPrefixes.find(prefix => fileKey.startsWith(prefix + '/')); const matchedPrefix = publicPrefixes.find(prefix => fileKey.startsWith(prefix + '/'));
if (matchedPrefix) { if (matchedPrefix) {
const prefixDepth = matchedPrefix.split('/').length; const prefixDepth = matchedPrefix.split('/').length;
if (pathParts.length < prefixDepth + 2) { if (pathParts.length < prefixDepth + 2) {
@@ -63,7 +60,7 @@ async function handleGetFile(_request, { wildcard: fileKey }) {
} }
// Require authentication for all other paths. // Require authentication for all other paths.
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionToken = cookieStore.get(COOKIE_NAME)?.value; const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
if (!sessionToken) { if (!sessionToken) {
@@ -77,8 +74,8 @@ async function handleGetFile(_request, { wildcard: fileKey }) {
} }
// Path-based access control driven by policies declared in each module. // Path-based access control driven by policies declared in each module.
const policies = getAllStorageAccessPolicies(); const policies = getStorageAccessPolicies();
const policy = policies.find(p => pathParts[0] === p.prefix); const policy = policies.find(p => pathParts[0] === p.prefix);
if (!policy) { if (!policy) {
return apiError('Forbidden', 'Invalid file path'); 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) { async function fetchFile(fileKey) {
const result = await getFile(fileKey); const result = await getFile(fileKey);
@@ -122,11 +115,11 @@ async function fetchFile(fileKey) {
return { return {
success: true, success: true,
file: { file: {
body: result.data.body, body: result.data.body,
contentType: result.data.contentType, contentType: result.data.contentType,
contentLength: result.data.contentLength, contentLength: result.data.contentLength,
lastModified: result.data.lastModified lastModified: result.data.lastModified,
} },
}; };
} }
+28
View File
@@ -0,0 +1,28 @@
/**
* Backblaze B2 provider config (S3-compatible API).
* Reads ZEN_STORAGE_B2_* environment variables.
*
* Endpoint format: s3.<region>.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 };
}
+23
View File
@@ -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 };
}
+56 -368
View File
@@ -1,259 +1,33 @@
/** import { createHash } from 'crypto';
* 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 { fail, warn, info } from '../../shared/lib/logger.js'; 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) { async function getConfig() {
return createHash('sha256').update(data).digest('hex'); 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) { // ─── Storage functions ────────────────────────────────────────────────────────
return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key)).update(data).digest();
}
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}[^>]*>(.*?)</${tag}>`, 's'));
return m ? m[1] : null;
}
function xmlAll(xml, tag) {
const re = new RegExp(`<${tag}[^>]*>(.*?)</${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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// ─── 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<Object>}
*/
async function uploadFile({ key, body, contentType, metadata = {}, cacheControl }) { async function uploadFile({ key, body, contentType, metadata = {}, cacheControl }) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}/${key}`; const path = `/${config.bucket}/${key}`;
const date = new Date(); const date = new Date();
const bodyBuffer = await toBuffer(body); const bodyBuffer = await toBuffer(body);
@@ -264,16 +38,7 @@ async function uploadFile({ key, body, contentType, metadata = {}, cacheControl
...(cacheControl && { 'cache-control': sanitizeHeaderValue(cacheControl) }), ...(cacheControl && { 'cache-control': sanitizeHeaderValue(cacheControl) }),
}; };
const { url, headers } = signRequest({ const { url, headers } = signRequest({ method: 'PUT', host: config.host, path, extraHeaders, bodyBuffer, config, date });
method: 'PUT',
host: config.host,
path,
extraHeaders,
bodyBuffer,
config,
date,
});
const response = await fetch(url, { method: 'PUT', headers, body: bodyBuffer }); const response = await fetch(url, { method: 'PUT', headers, body: bodyBuffer });
if (!response.ok) { 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<Object>}
*/
async function uploadImage({ key, body, contentType, metadata = {} }) { async function uploadImage({ key, body, contentType, metadata = {} }) {
return uploadFile({ key, body, contentType, metadata, cacheControl: 'public, max-age=31536000' }); 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<Object>}
*/
async function deleteFile(key) { async function deleteFile(key) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}/${key}`; const path = `/${config.bucket}/${key}`;
const date = new Date(); 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<Object>}
*/
async function deleteFiles(keys) { async function deleteFiles(keys) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}`; const path = `/${config.bucket}`;
const date = new Date(); const date = new Date();
@@ -365,9 +111,9 @@ async function deleteFiles(keys) {
const xml = await response.text(); const xml = await response.text();
const deleted = xmlAll(xml, 'Deleted').map(b => ({ Key: xmlFirst(b, 'Key') })); const deleted = xmlAll(xml, 'Deleted').map(b => ({ Key: xmlFirst(b, 'Key') }));
const errors = xmlAll(xml, 'Error').map(b => ({ const errors = xmlAll(xml, 'Error').map(b => ({
Key: xmlFirst(b, 'Key'), Key: xmlFirst(b, 'Key'),
Code: xmlFirst(b, 'Code'), Code: xmlFirst(b, 'Code'),
Message: xmlFirst(b, 'Message'), 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<Object>} File data with metadata
*/
async function getFile(key) { async function getFile(key) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}/${key}`; const path = `/${config.bucket}/${key}`;
const date = new Date(); const date = new Date();
@@ -404,9 +145,9 @@ async function getFile(key) {
data: { data: {
key, key,
body: buffer, body: buffer,
contentType: response.headers.get('content-type'), contentType: response.headers.get('content-type'),
contentLength: Number(response.headers.get('content-length')), 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')) ? new Date(response.headers.get('last-modified'))
: null, : null,
metadata: headersToMeta(response.headers), 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<Object>} File metadata
*/
async function getFileMetadata(key) { async function getFileMetadata(key) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}/${key}`; const path = `/${config.bucket}/${key}`;
const date = new Date(); const date = new Date();
@@ -441,13 +177,13 @@ async function getFileMetadata(key) {
success: true, success: true,
data: { data: {
key, key,
contentType: response.headers.get('content-type'), contentType: response.headers.get('content-type'),
contentLength: Number(response.headers.get('content-length')), 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')) ? new Date(response.headers.get('last-modified'))
: null, : null,
metadata: headersToMeta(response.headers), metadata: headersToMeta(response.headers),
etag: response.headers.get('etag'), etag: response.headers.get('etag'),
}, },
error: null, 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<boolean>}
*/
async function fileExists(key) { async function fileExists(key) {
const result = await getFileMetadata(key); const result = await getFileMetadata(key);
return result.success; 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<Object>}
*/
async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {}) { async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {}) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}`; const path = `/${config.bucket}`;
const date = new Date(); const date = new Date();
@@ -486,9 +209,9 @@ async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {}
const query = { const query = {
'list-type': '2', 'list-type': '2',
'max-keys': String(validMaxKeys), 'max-keys': String(validMaxKeys),
...(prefix && { prefix }), ...(prefix && { prefix }),
...(continuationToken && { 'continuation-token': continuationToken }), ...(continuationToken && { 'continuation-token': continuationToken }),
}; };
const { url, headers } = signRequest({ method: 'GET', host: config.host, path, query, config, date }); 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 xml = await response.text();
const isTruncated = xmlFirst(xml, 'IsTruncated') === 'true'; const isTruncated = xmlFirst(xml, 'IsTruncated') === 'true';
const nextContinuationToken = xmlFirst(xml, 'NextContinuationToken'); const nextContinuationToken = xmlFirst(xml, 'NextContinuationToken');
const files = xmlAll(xml, 'Contents').map(block => ({ const files = xmlAll(xml, 'Contents').map(block => ({
key: xmlFirst(block, 'Key'), key: xmlFirst(block, 'Key'),
size: parseInt(xmlFirst(block, 'Size') || '0', 10), size: parseInt(xmlFirst(block, 'Size') || '0', 10),
lastModified: xmlFirst(block, 'LastModified') ? new Date(xmlFirst(block, 'LastModified')) : null, lastModified: xmlFirst(block, 'LastModified') ? new Date(xmlFirst(block, 'LastModified')) : null,
etag: (xmlFirst(block, 'ETag') || '').replace(/"/g, ''), etag: (xmlFirst(block, 'ETag') || '').replace(/"/g, ''),
})); }));
return { return { success: true, data: { files, isTruncated, nextContinuationToken, count: files.length }, error: null };
success: true,
data: { files, isTruncated, nextContinuationToken, count: files.length },
error: null,
};
} catch (error) { } catch (error) {
fail(`Storage list files failed: ${error.message}`); fail(`Storage list files failed: ${error.message}`);
return { success: false, data: null, error: 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<Object>}
*/
async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) { async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}/${key}`; const path = `/${config.bucket}/${key}`;
const date = new Date(); const date = new Date();
const method = operation === 'put' ? 'PUT' : 'GET'; const method = operation === 'put' ? 'PUT' : 'GET';
// R2/S3 max presigned URL lifetime is 7 days (604800 seconds) // R2/S3 max presigned URL lifetime is 7 days (604800 seconds)
const validExpiresIn = Math.min(Math.max(Math.floor(Number(expiresIn)), 1), 604800); const validExpiresIn = Math.min(Math.max(Math.floor(Number(expiresIn)), 1), 604800);
const url = buildPresignedUrl({ method, host: config.host, path, expiresIn: validExpiresIn, config, date }); const url = buildPresignedUrl({ method, host: config.host, path, expiresIn: validExpiresIn, config, date });
return { success: true, data: { key, url, expiresIn: validExpiresIn, operation }, error: null }; 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<Object>}
*/
async function copyFile({ sourceKey, destinationKey }) { async function copyFile({ sourceKey, destinationKey }) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}/${destinationKey}`; const path = `/${config.bucket}/${destinationKey}`;
const date = new Date(); const date = new Date();
const { url, headers } = signRequest({ const { url, headers } = signRequest({
method: 'PUT', 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<Object>}
*/
async function proxyFile(key, { filename } = {}) { async function proxyFile(key, { filename } = {}) {
const result = await getFile(key); const result = await getFile(key);
if (!result.success) return { success: false, error: result.error }; if (!result.success) return { success: false, error: result.error };
return { return {
success: true, success: true,
file: { file: {
body: result.data.body, body: result.data.body,
contentType: result.data.contentType, contentType: result.data.contentType,
contentLength: result.data.contentLength, contentLength: result.data.contentLength,
...(filename && { filename }), ...(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<Object>}
*/
async function moveFile({ sourceKey, destinationKey }) { async function moveFile({ sourceKey, destinationKey }) {
try { try {
const copyResult = await copyFile({ sourceKey, destinationKey }); const copyResult = await copyFile({ sourceKey, destinationKey });
@@ -634,7 +320,8 @@ async function moveFile({ sourceKey, destinationKey }) {
} }
} }
// Export utility functions // ─── Exports ──────────────────────────────────────────────────────────────────
export { export {
generateUniqueFilename, generateUniqueFilename,
getFileExtension, getFileExtension,
@@ -648,7 +335,6 @@ export {
FILE_SIZE_LIMITS, FILE_SIZE_LIMITS,
} from './utils.js'; } from './utils.js';
// Export storage functions
export { export {
uploadFile, uploadFile,
uploadImage, uploadImage,
@@ -663,3 +349,5 @@ export {
copyFile, copyFile,
moveFile, moveFile,
}; };
export { configureStorageApi } from './storage-config.js';
+198
View File
@@ -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}[^>]*>(.*?)</${tag}>`, 's'));
return m ? m[1] : null;
}
export function xmlAll(xml, tag) {
const re = new RegExp(`<${tag}[^>]*>(.*?)</${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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// ─── 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])
);
}
+21
View File
@@ -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();
}
+4 -6
View File
@@ -1,12 +1,10 @@
/** /**
* Admin Server Actions * 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. * server-side code (which includes database imports) into client components.
* *
* Usage: * Usage: import { getDashboardStats } from '@zen/core/admin/actions';
* import { getDashboardStats, getModuleDashboardStats } from '@zen/core/admin/actions';
*/ */
export { getDashboardStats } from './actions/statsActions.js'; export { getDashboardStats } from './actions/statsActions.js';
export { getAllModuleDashboardStats as getModuleDashboardStats } from '@zen/core/modules/actions';
+3 -15
View File
@@ -15,20 +15,12 @@
* Icons are passed as string names and resolved on the client. * 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 * Build complete navigation sections
* This should ONLY be called on the server (in page.js)
* @param {string} pathname - Current pathname * @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) * @returns {Array} Complete navigation sections (serializable, icons as strings)
*/ */
export function buildNavigationSections(pathname, enabledModules = null) { export function buildNavigationSections(pathname) {
// Core navigation sections (always available)
// Use icon NAMES (strings) for serialization across server/client boundary
const coreNavigation = [ const coreNavigation = [
{ {
id: 'Dashboard', 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 = [ const systemNavigation = [
{ {
id: 'users', id: 'users',
@@ -65,5 +53,5 @@ export function buildNavigationSections(pathname, enabledModules = null) {
} }
]; ];
return [...coreNavigation, ...moduleNavigation, ...systemNavigation]; return [...coreNavigation, ...systemNavigation];
} }
+21 -80
View File
@@ -1,134 +1,75 @@
/** /**
* Admin Page - Server Component Wrapper for Next.js App Router * Admin Page - Server Component Wrapper for Next.js App Router
* *
* This is a complete server component that handles all admin routes. * Re-export this in your app/admin/[...admin]/page.js:
* Users can simply re-export this in their app/admin/[...admin]/page.js:
*
* ```javascript
* export { default } from '@zen/core/admin/page'; * 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 { AdminPagesLayout, AdminPagesClient } from '@zen/core/admin/pages';
import { protectAdmin } from '@zen/core/admin'; import { protectAdmin } from '@zen/core/admin';
import { buildNavigationSections } from '@zen/core/admin/navigation'; 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 { 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) { function parseAdminRoute(params) {
const parts = params?.admin || []; const parts = params?.admin || [];
if (parts.length === 0) { 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']; const corePages = ['dashboard', 'users', 'profile'];
if (corePages.includes(parts[0])) { if (corePages.includes(parts[0])) {
// Users: support /admin/users/edit/:id
if (parts[0] === 'users' && parts[1] === 'edit' && parts[2]) { 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 pathParts = [];
let action = null; let action = null;
let id = null; let id = null;
const actionKeywords = ['new', 'create', 'edit'];
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
const part = parts[i]; const part = parts[i];
if (actionKeywords.includes(part)) { if (actionKeywords.includes(part)) {
action = part === 'create' ? 'new' : part; action = part === 'create' ? 'new' : part;
// If it's 'edit', the next part is the ID
if (action === 'edit' && i + 1 < parts.length) { if (action === 'edit' && i + 1 < parts.length) {
id = parts[i + 1]; id = parts[i + 1];
} }
break; break;
} }
pathParts.push(part); pathParts.push(part);
} }
// Build the full path
let fullPath = '/admin/' + pathParts.join('/');
if (action) {
fullPath += '/' + action;
}
return { path: fullPath, action, id, isCorePage: false };
}
/** return { path: '/admin/' + pathParts.join('/') + (action ? '/' + action : ''), action, id };
* 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;
} }
export default async function AdminPage({ params }) { export default async function AdminPage({ params }) {
const resolvedParams = await params; const resolvedParams = await params;
const session = await protectAdmin(); const session = await protectAdmin();
const appName = getAppName(); const appName = getAppName();
const enabledModules = getModulesConfig();
const config = getAppConfig();
const statsResult = await getDashboardStats(); const statsResult = await getDashboardStats();
const dashboardStats = statsResult.success ? statsResult.stats : null; const dashboardStats = statsResult.success ? statsResult.stats : null;
// Fetch module dashboard stats for widgets const navigationSections = buildNavigationSections('/');
const moduleStats = await getModuleDashboardStats(); const { path, action, id } = parseAdminRoute(resolvedParams);
// 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);
return ( return (
<AdminPagesLayout <AdminPagesLayout
user={session.user} user={session.user}
onLogout={logoutAction} onLogout={logoutAction}
appName={appName} appName={appName}
enabledModules={enabledModules}
navigationSections={navigationSections} navigationSections={navigationSections}
> >
<AdminPagesClient <AdminPagesClient
params={resolvedParams} params={resolvedParams}
user={session.user} user={session.user}
dashboardStats={dashboardStats} dashboardStats={dashboardStats}
moduleStats={moduleStats}
modulePageInfo={modulePageInfo}
routeInfo={{ path, action, id }} routeInfo={{ path, action, id }}
enabledModules={enabledModules}
/> />
</AdminPagesLayout> </AdminPagesLayout>
); );
+1 -10
View File
@@ -23,20 +23,11 @@ export * as stripe from "./core/payments/stripe.js";
// Export PDF utilities as namespace // Export PDF utilities as namespace
export * as pdf from "./core/pdf/index.js"; 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' // NOTE: Toast components are CLIENT ONLY - import from '@zen/core/toast'
// Do not export here to avoid mixing client/server boundaries // 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 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 initialization utilities
export { initializeZen, resetZenInitialization } from "./shared/lib/init.js"; export { initializeZen, resetZenInitialization } from "./shared/lib/init.js";
-27
View File
@@ -3,8 +3,6 @@
* Centralized configuration management for the entire package * Centralized configuration management for the entire package
*/ */
import { getAvailableModules } from '../../modules/modules.registry.js';
/** /**
* Get application name from environment variables * Get application name from environment variables
* @returns {string} Application name * @returns {string} Application name
@@ -36,28 +34,6 @@ export function getPublicBaseUrl() {
return String(raw).replace(/\/$/, ''); 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 * Get application configuration
* @returns {Object} Application configuration object * @returns {Object} Application configuration object
@@ -68,10 +44,7 @@ export function getAppConfig() {
sessionCookieName: getSessionCookieName(), sessionCookieName: getSessionCookieName(),
timezone: process.env.ZEN_TIMEZONE || 'America/Toronto', timezone: process.env.ZEN_TIMEZONE || 'America/Toronto',
dateFormat: process.env.ZEN_DATE_FORMAT || 'YYYY-MM-DD', dateFormat: process.env.ZEN_DATE_FORMAT || 'YYYY-MM-DD',
// Currency configuration (for currency module)
defaultCurrency: process.env.ZEN_CURRENCY || 'CAD', defaultCurrency: process.env.ZEN_CURRENCY || 'CAD',
currencySymbol: process.env.ZEN_CURRENCY_SYMBOL || '$', currencySymbol: process.env.ZEN_CURRENCY_SYMBOL || '$',
// Enabled modules
modules: getModulesConfig(),
}; };
} }
+15 -102
View File
@@ -1,136 +1,49 @@
/** /**
* ZEN Initialization * 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 * Wires core feature dependencies into the API router.
* Alternative: Call this function manually in your root layout * This is the composition root — the only place that connects features to core.
* *
* @example * @example
* // instrumentation.js (Recommended) — internal modules only * // instrumentation.js
* export async function register() { * export async function register() {
* if (process.env.NEXT_RUNTIME === 'nodejs') { * if (process.env.NEXT_RUNTIME === 'nodejs') {
* const { initializeZen } = await import('@zen/core'); * const { initializeZen } = await import('@zen/core');
* await initializeZen(); * 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<Object>} 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') { if (typeof window !== 'undefined') {
return { skipped: true, reason: 'client-side' }; return { skipped: true, reason: 'client-side' };
} }
// Prevent multiple initializations using globalThis
if (globalThis[ZEN_INIT_KEY]) { if (globalThis[ZEN_INIT_KEY]) {
warn('ZEN: already initialized, skipping'); warn('ZEN: already initialized, skipping');
return { skipped: true, reason: 'already-initialized' }; return { skipped: true, reason: 'already-initialized' };
} }
globalThis[ZEN_INIT_KEY] = true; globalThis[ZEN_INIT_KEY] = true;
step('ZEN starting...');
const result = { configureRouter({ resolveSession: validateSession });
discovery: null, registerFeatureRoutes(authRoutes);
cron: { started: [], errors: [] }
};
try { done('ZEN: ready');
// 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) return {};
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;
} }
/**
* Reset initialization flag (useful for testing or manual reinitialization)
* @returns {void}
*/
export function resetZenInitialization() { export function resetZenInitialization() {
globalThis[ZEN_INIT_KEY] = false; 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(); clearRouterConfig();
clearFeatureRoutes(); clearFeatureRoutes();
warn('ZEN: initialization reset'); warn('ZEN: initialization reset');
} }