chadstart 1.0.5 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/core/email.js ADDED
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Email sending service for ChadStart.
5
+ *
6
+ * SMTP connection details can be configured in the YAML `email` section
7
+ * or via environment variables (env vars always take precedence):
8
+ *
9
+ * SMTP_HOST SMTP server hostname
10
+ * SMTP_PORT SMTP server port (default: 587)
11
+ * SMTP_USER SMTP username / login
12
+ * SMTP_PASS SMTP password (secret — env var only)
13
+ * SMTP_FROM Default "From" address (e.g. "App <noreply@example.com>")
14
+ *
15
+ * Templates support simple {{variable}} interpolation.
16
+ */
17
+
18
+ const nodemailer = require('nodemailer');
19
+ const logger = require('../utils/logger');
20
+
21
+ /** @type {import('nodemailer').Transporter | null} */
22
+ let _transporter = null;
23
+
24
+ /** @type {{ host: string, port: number, from: string, secure: boolean } | null} */
25
+ let _emailConfig = null;
26
+
27
+ /**
28
+ * Derive email/SMTP configuration from YAML + environment variables.
29
+ * Returns null when no SMTP host is configured (email sending is disabled).
30
+ *
31
+ * @param {object|null} emailYaml Value of `core.email` (may be null).
32
+ * @returns {{ host: string, port: number, user: string, pass: string, from: string, secure: boolean } | null}
33
+ */
34
+ function getEmailConfig(emailYaml) {
35
+ const cfg = emailYaml || {};
36
+
37
+ const host = process.env.SMTP_HOST || cfg.host || '';
38
+ if (!host) return null;
39
+
40
+ const port = parseInt(process.env.SMTP_PORT || cfg.port || '587', 10);
41
+ const user = process.env.SMTP_USER || cfg.username || '';
42
+ const pass = process.env.SMTP_PASS || '';
43
+ const from = process.env.SMTP_FROM || cfg.from || '';
44
+ const secure = cfg.secure !== undefined
45
+ ? cfg.secure
46
+ : port === 465;
47
+
48
+ return { host, port, user, pass, from, secure };
49
+ }
50
+
51
+ /**
52
+ * Initialize the email transporter.
53
+ * Safe to call multiple times (recreates on each call for hot-reload support).
54
+ *
55
+ * @param {object|null} emailYaml Value of `core.email` (may be null).
56
+ * @returns {{ host: string, port: number, from: string, secure: boolean } | null} The resolved config, or null if disabled.
57
+ */
58
+ function initEmail(emailYaml) {
59
+ _transporter = null;
60
+ _emailConfig = null;
61
+
62
+ const cfg = getEmailConfig(emailYaml);
63
+ if (!cfg) {
64
+ logger.info(' Email/SMTP not configured — email sending disabled.');
65
+ return null;
66
+ }
67
+
68
+ const transportOpts = {
69
+ host: cfg.host,
70
+ port: cfg.port,
71
+ secure: cfg.secure,
72
+ };
73
+
74
+ if (cfg.user) {
75
+ transportOpts.auth = { user: cfg.user, pass: cfg.pass };
76
+ }
77
+
78
+ _transporter = nodemailer.createTransport(transportOpts);
79
+ _emailConfig = { host: cfg.host, port: cfg.port, from: cfg.from, secure: cfg.secure };
80
+
81
+ logger.info(` Email/SMTP configured (host: ${cfg.host}:${cfg.port})`);
82
+ return _emailConfig;
83
+ }
84
+
85
+ /**
86
+ * Replace `{{variable}}` placeholders in a template string.
87
+ *
88
+ * @param {string} template Template with `{{key}}` placeholders.
89
+ * @param {Record<string, string>} vars Key→value map.
90
+ * @returns {string}
91
+ */
92
+ function interpolate(template, vars) {
93
+ if (!template) return '';
94
+ return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
95
+ return Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : '';
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Send an email using the configured SMTP transporter.
101
+ *
102
+ * @param {object} options
103
+ * @param {string} options.to Recipient email address.
104
+ * @param {string} options.subject Email subject (supports {{var}} interpolation).
105
+ * @param {string} [options.text] Plain-text body (supports {{var}} interpolation).
106
+ * @param {string} [options.html] HTML body (supports {{var}} interpolation).
107
+ * @param {string} [options.from] Override the default "From" address.
108
+ * @param {Record<string, string>} [options.vars] Variables for template interpolation.
109
+ * @returns {Promise<object>} Nodemailer send result.
110
+ * @throws {Error} When SMTP is not configured or sending fails.
111
+ */
112
+ async function sendEmail({ to, subject, text, html, from, vars }) {
113
+ if (!_transporter || !_emailConfig) {
114
+ throw new Error('Email is not configured. Set SMTP_HOST or configure the email section in your YAML config.');
115
+ }
116
+
117
+ const templateVars = vars || {};
118
+ const mailOptions = {
119
+ from: from || _emailConfig.from,
120
+ to,
121
+ subject: interpolate(subject, templateVars),
122
+ };
123
+
124
+ if (text) mailOptions.text = interpolate(text, templateVars);
125
+ if (html) mailOptions.html = interpolate(html, templateVars);
126
+
127
+ return _transporter.sendMail(mailOptions);
128
+ }
129
+
130
+ /**
131
+ * Verify the SMTP connection by attempting a handshake with the server.
132
+ *
133
+ * @returns {Promise<{ success: boolean, message: string }>}
134
+ */
135
+ async function verifyConnection() {
136
+ if (!_transporter || !_emailConfig) {
137
+ return { success: false, message: 'Email is not configured. Set SMTP_HOST or configure the email section in your YAML config.' };
138
+ }
139
+ try {
140
+ await _transporter.verify();
141
+ return { success: true, message: `SMTP connection to ${_emailConfig.host}:${_emailConfig.port} verified.` };
142
+ } catch (err) {
143
+ return { success: false, message: `SMTP verification failed: ${err.message}` };
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Returns the current email configuration metadata (without secrets).
149
+ *
150
+ * @returns {{ configured: boolean, host?: string, port?: number, from?: string, secure?: boolean }}
151
+ */
152
+ function getEmailStatus() {
153
+ if (!_emailConfig) return { configured: false };
154
+ return {
155
+ configured: true,
156
+ host: _emailConfig.host,
157
+ port: _emailConfig.port,
158
+ from: _emailConfig.from,
159
+ secure: _emailConfig.secure,
160
+ };
161
+ }
162
+
163
+ module.exports = {
164
+ getEmailConfig,
165
+ initEmail,
166
+ interpolate,
167
+ sendEmail,
168
+ verifyConnection,
169
+ getEmailStatus,
170
+ };
@@ -60,6 +60,7 @@ function buildEntities(config) {
60
60
  tableName: toSnakeCase(name),
61
61
  slug: def.slug || toKebabCase(name),
62
62
  authenticable: def.authenticable === true,
63
+ requireEmailVerification: def.requireEmailVerification === true,
63
64
  single: def.single === true,
64
65
  mainProp: def.mainProp || null,
65
66
  nameSingular: def.nameSingular || null,
@@ -155,6 +156,9 @@ function buildCore(config) {
155
156
  port: parseInt(process.env.CHADSTART_PORT || process.env.PORT || config.port || 3000, 10),
156
157
  rateLimits,
157
158
  telemetry,
159
+ email: config.email || null,
160
+ logs: config.logs || null,
161
+ backup: config.backup || null,
158
162
  oauth: config.oauth || null,
159
163
  admin: {
160
164
  enable_app: adminCfg.enable_app !== false,
package/core/logs.js ADDED
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Request logging module for ChadStart.
5
+ *
6
+ * Stores API request logs in the `_cs_logs` system table and provides
7
+ * a paginated, filterable query API for the admin dashboard.
8
+ *
9
+ * Configuration via YAML `logs` section:
10
+ * logs:
11
+ * retention: 30 # Days to keep logs (default: 30, 0 = forever)
12
+ * exclude: # Paths to exclude from logging
13
+ * - /health
14
+ * - /admin/vendor
15
+ */
16
+
17
+ const db = require('./db');
18
+ const { q } = db;
19
+ const logger = require('../utils/logger');
20
+
21
+ const _DB_ENGINE = db.DB_ENGINE;
22
+ const _ID_T = _DB_ENGINE === 'mysql' ? 'VARCHAR(36)' : 'TEXT';
23
+ const _NAME_T = _DB_ENGINE === 'mysql' ? 'VARCHAR(255)' : 'TEXT';
24
+
25
+ /** Default log retention in days. 0 = keep forever. */
26
+ const DEFAULT_RETENTION_DAYS = 30;
27
+
28
+ /**
29
+ * Initialize the _cs_logs system table.
30
+ * Must be called after initDb().
31
+ */
32
+ async function initLogs() {
33
+ await db.exec(`
34
+ CREATE TABLE IF NOT EXISTS ${q('_cs_logs')} (
35
+ ${q('id')} ${_ID_T} PRIMARY KEY,
36
+ ${q('method')} ${_NAME_T},
37
+ ${q('path')} TEXT,
38
+ ${q('statusCode')} INTEGER,
39
+ ${q('duration')} INTEGER,
40
+ ${q('ip')} ${_NAME_T},
41
+ ${q('userId')} ${_NAME_T},
42
+ ${q('userEntity')} ${_NAME_T},
43
+ ${q('createdAt')} TEXT NOT NULL
44
+ )
45
+ `);
46
+ }
47
+
48
+ /**
49
+ * Insert a log entry.
50
+ */
51
+ async function insertLog({ method, path, statusCode, duration, ip, userId, userEntity }) {
52
+ const id = require('crypto').randomUUID();
53
+ const now = new Date().toISOString();
54
+ await db.queryRun(
55
+ `INSERT INTO ${q('_cs_logs')} (${q('id')},${q('method')},${q('path')},${q('statusCode')},${q('duration')},${q('ip')},${q('userId')},${q('userEntity')},${q('createdAt')})
56
+ VALUES (?,?,?,?,?,?,?,?,?)`,
57
+ [id, method, path, statusCode, duration, ip || null, userId || null, userEntity || null, now]
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Express middleware that logs each request to the _cs_logs table.
63
+ *
64
+ * @param {object} opts
65
+ * @param {string[]} opts.exclude Path prefixes to skip logging (e.g. ['/health']).
66
+ */
67
+ function requestLoggerMiddleware(opts = {}) {
68
+ const exclude = opts.exclude || [];
69
+ return (req, res, next) => {
70
+ // Skip excluded paths
71
+ for (const prefix of exclude) {
72
+ if (req.path.startsWith(prefix)) return next();
73
+ }
74
+
75
+ const start = Date.now();
76
+
77
+ // Hook into response finish event
78
+ const originalEnd = res.end;
79
+ res.end = function (...args) {
80
+ res.end = originalEnd;
81
+ res.end(...args);
82
+
83
+ const duration = Date.now() - start;
84
+ const userId = (req.user && req.user.id) || null;
85
+ const userEntity = (req.user && req.user.entity) || null;
86
+
87
+ // Insert asynchronously (best-effort, don't block response)
88
+ insertLog({
89
+ method: req.method,
90
+ path: req.originalUrl || req.path,
91
+ statusCode: res.statusCode,
92
+ duration,
93
+ ip: req.ip || req.socket?.remoteAddress || null,
94
+ userId,
95
+ userEntity,
96
+ }).catch((e) => logger.warn('Log insert failed:', e.message));
97
+ };
98
+
99
+ next();
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Query logs with pagination and filters.
105
+ *
106
+ * @param {object} filters Optional filters: { method, statusCode, path, from, to }
107
+ * @param {object} opts { page, perPage, order }
108
+ * @returns {{ data: object[], total: number, currentPage: number, lastPage: number, perPage: number }}
109
+ */
110
+ async function queryLogs(filters = {}, opts = {}) {
111
+ const page = Math.max(1, parseInt(opts.page || 1, 10));
112
+ const perPage = Math.min(100, Math.max(1, parseInt(opts.perPage || 50, 10)));
113
+ const order = (opts.order || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
114
+
115
+ const where = [];
116
+ const params = [];
117
+
118
+ if (filters.method) {
119
+ where.push(`${q('method')} = ?`);
120
+ params.push(filters.method.toUpperCase());
121
+ }
122
+ if (filters.statusCode) {
123
+ where.push(`${q('statusCode')} = ?`);
124
+ params.push(parseInt(filters.statusCode, 10));
125
+ }
126
+ if (filters.path) {
127
+ where.push(`${q('path')} LIKE ?`);
128
+ params.push(`%${filters.path}%`);
129
+ }
130
+ if (filters.from) {
131
+ where.push(`${q('createdAt')} >= ?`);
132
+ params.push(filters.from);
133
+ }
134
+ if (filters.to) {
135
+ where.push(`${q('createdAt')} <= ?`);
136
+ params.push(filters.to);
137
+ }
138
+
139
+ const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
140
+
141
+ const countRow = await db.queryOne(
142
+ `SELECT COUNT(*) AS cnt FROM ${q('_cs_logs')} ${whereClause}`,
143
+ params
144
+ );
145
+ const total = countRow ? countRow.cnt : 0;
146
+ const lastPage = Math.max(1, Math.ceil(total / perPage));
147
+ const offset = (page - 1) * perPage;
148
+
149
+ const data = await db.queryAll(
150
+ `SELECT * FROM ${q('_cs_logs')} ${whereClause} ORDER BY ${q('createdAt')} ${order} LIMIT ? OFFSET ?`,
151
+ [...params, perPage, offset]
152
+ );
153
+
154
+ return { data, total, currentPage: page, lastPage, perPage };
155
+ }
156
+
157
+ /**
158
+ * Delete logs older than the specified number of days.
159
+ *
160
+ * @param {number} days Log retention in days. 0 or negative = keep all.
161
+ * @returns {number} Number of rows deleted.
162
+ */
163
+ async function cleanupOldLogs(days) {
164
+ if (!days || days <= 0) return 0;
165
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
166
+ const result = await db.queryRun(
167
+ `DELETE FROM ${q('_cs_logs')} WHERE ${q('createdAt')} < ?`,
168
+ [cutoff]
169
+ );
170
+ return result?.changes || 0;
171
+ }
172
+
173
+ module.exports = {
174
+ initLogs,
175
+ insertLog,
176
+ requestLoggerMiddleware,
177
+ queryLogs,
178
+ cleanupOldLogs,
179
+ };
package/core/openapi.js CHANGED
@@ -30,8 +30,12 @@ function generateOpenApiSpec(core) {
30
30
  spec.components.schemas[`${e.name}AuthResponse`] = { type: 'object', properties: { token: { type: 'string' }, user: { $ref: `#/components/schemas/${e.name}` } } };
31
31
 
32
32
  spec.paths[`/api/auth/${slug}/signup`] = { post: { tags: [`Auth – ${e.name}`], summary: `Sign up as ${e.name}`, requestBody: jsonBody(`${e.name}Input`), responses: { 201: jsonResp(e.name + 'AuthResponse'), 400: desc('Validation error'), 409: desc('Email already registered') } } };
33
- spec.paths[`/api/auth/${slug}/login`] = { post: { tags: [`Auth – ${e.name}`], summary: `Login as ${e.name}`, requestBody: jsonBody(`${e.name}LoginInput`), responses: { 200: jsonResp(e.name + 'AuthResponse'), 401: desc('Invalid credentials') } } };
33
+ spec.paths[`/api/auth/${slug}/login`] = { post: { tags: [`Auth – ${e.name}`], summary: `Login as ${e.name}`, requestBody: jsonBody(`${e.name}LoginInput`), responses: { 200: jsonResp(e.name + 'AuthResponse'), 401: desc('Invalid credentials'), 403: desc('Email not verified') } } };
34
34
  spec.paths[`/api/auth/${slug}/me`] = { get: { tags: [`Auth – ${e.name}`], summary: `Get current ${e.name}`, security: [{ bearerAuth: [] }], responses: { 200: jsonResp(e.name), 401: desc('Unauthorized') } } };
35
+ spec.paths[`/api/auth/${slug}/request-verification`] = { post: { tags: [`Auth – ${e.name}`], summary: `Request email verification for ${e.name}`, security: [{ bearerAuth: [] }], responses: { 200: desc('Verification email sent'), 401: desc('Unauthorized') } } };
36
+ spec.paths[`/api/auth/${slug}/confirm-verification`] = { post: { tags: [`Auth – ${e.name}`], summary: `Confirm email verification for ${e.name}`, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['token'], properties: { token: { type: 'string' } } } } } }, responses: { 200: desc('Email verified'), 400: desc('Invalid token') } } };
37
+ spec.paths[`/api/auth/${slug}/request-password-reset`] = { post: { tags: [`Auth – ${e.name}`], summary: `Request password reset for ${e.name}`, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email'], properties: { email: { type: 'string', format: 'email' } } } } } }, responses: { 200: desc('Password reset email sent (if account exists)') } } };
38
+ spec.paths[`/api/auth/${slug}/confirm-password-reset`] = { post: { tags: [`Auth – ${e.name}`], summary: `Confirm password reset for ${e.name}`, requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['token', 'password'], properties: { token: { type: 'string' }, password: { type: 'string', format: 'password' } } } } } }, responses: { 200: desc('Password reset successful'), 400: desc('Invalid or expired token') } } };
35
39
  }
36
40
 
37
41
  // Entity CRUD endpoints
@@ -138,7 +142,7 @@ function entityInputSchema(e, all) {
138
142
  }
139
143
 
140
144
  function authSchema(e) {
141
- const props = { id: { type: 'string', format: 'uuid', readOnly: true }, email: { type: 'string', format: 'email' }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' } };
145
+ const props = { id: { type: 'string', format: 'uuid', readOnly: true }, email: { type: 'string', format: 'email' }, emailVerified: { type: 'boolean' }, createdAt: { type: 'string', format: 'date-time' }, updatedAt: { type: 'string', format: 'date-time' } };
142
146
  for (const p of e.properties) {
143
147
  if (!p.hidden) props[p.name] = { type: OPENAPI_TYPE[p.type] || 'string' };
144
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chadstart",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "YAML-first Backend as a Service — define your entire backend in one YAML file",
5
5
  "main": "server/express-server.js",
6
6
  "bin": {
@@ -53,6 +53,7 @@
53
53
  "jsonwebtoken": "^9.0.3",
54
54
  "mysql2": "^3.20.0",
55
55
  "node-cron": "^4.2.1",
56
+ "nodemailer": "^8.0.4",
56
57
  "pg": "^8.20.0",
57
58
  "postgrator": "^8.0.0",
58
59
  "sharp": "^0.34.5",
@@ -24,6 +24,9 @@ const { initErrorReporter, getRequestHandler, attachErrorHandler } = require('..
24
24
  const { getTelemetryConfig, initTelemetry } = require('../core/telemetry');
25
25
  const { setupFunctions, cleanup: cleanupFunctions } = require('../core/functions-engine');
26
26
  const { registerOAuthRoutes } = require('../core/oauth');
27
+ const { initEmail, sendEmail, verifyConnection, getEmailStatus } = require('../core/email');
28
+ const { initLogs, requestLoggerMiddleware, queryLogs, cleanupOldLogs } = require('../core/logs');
29
+ const { createBackup, restoreBackup, listBackups } = require('../core/backup');
27
30
  const logger = require('../utils/logger');
28
31
 
29
32
  function limiter(windowMs, max) {
@@ -100,11 +103,24 @@ async function buildApp(configPath, reloadFn) {
100
103
  const telConfig = getTelemetryConfig(core.telemetry);
101
104
  await initTelemetry(telConfig);
102
105
 
106
+ // Initialize email/SMTP service
107
+ initEmail(core.email);
108
+
103
109
  const dbPath = core.database
104
110
  ? path.resolve(path.dirname(configPath), core.database)
105
111
  : undefined;
106
112
  await initDb(core, dbPath);
107
113
  await initApiKeys();
114
+ await initLogs();
115
+
116
+ // Schedule periodic log cleanup
117
+ const logsCfg = core.logs || {};
118
+ const retentionDays = logsCfg.retention !== undefined ? logsCfg.retention : 30;
119
+ if (retentionDays > 0) {
120
+ // Run cleanup once at startup and then every 24 hours
121
+ cleanupOldLogs(retentionDays).catch(() => {});
122
+ setInterval(() => cleanupOldLogs(retentionDays).catch(() => {}), 24 * 60 * 60 * 1000).unref();
123
+ }
108
124
 
109
125
  initErrorReporter(core);
110
126
 
@@ -117,6 +133,10 @@ async function buildApp(configPath, reloadFn) {
117
133
  const sentryRequestHandler = getRequestHandler();
118
134
  if (sentryRequestHandler) app.use(sentryRequestHandler);
119
135
 
136
+ // Request logging middleware — placed after Sentry but before routes
137
+ const logExclude = (logsCfg.exclude || ['/health', '/admin/vendor']);
138
+ app.use(requestLoggerMiddleware({ exclude: logExclude }));
139
+
120
140
  // Public static files
121
141
  if (core.public && core.public.folder) {
122
142
  const publicDir = path.resolve(core.public.folder);
@@ -280,6 +300,43 @@ async function buildApp(configPath, reloadFn) {
280
300
  }
281
301
  });
282
302
 
303
+ // ── Admin email endpoints ──────────────────────────────────────────────
304
+ // GET /admin/email/status — check if SMTP is configured
305
+ app.get('/admin/email/status', adminRateLimiter, (req, res) => {
306
+ const header = req.headers.authorization;
307
+ if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
308
+ try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
309
+ res.json(getEmailStatus());
310
+ });
311
+
312
+ // POST /admin/test-email — send a test email to verify SMTP configuration (auth required)
313
+ app.post('/admin/test-email', adminRateLimiter, async (req, res) => {
314
+ const header = req.headers.authorization;
315
+ if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
316
+ try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
317
+
318
+ const { to } = req.body || {};
319
+ if (!to || typeof to !== 'string') return res.status(400).json({ error: 'to (email address) is required' });
320
+
321
+ // First verify the SMTP connection
322
+ const verification = await verifyConnection();
323
+ if (!verification.success) return res.status(503).json(verification);
324
+
325
+ try {
326
+ const safeName = escAdminHtml(core.name);
327
+ await sendEmail({
328
+ to,
329
+ subject: 'ChadStart Test Email',
330
+ text: `This is a test email from your ChadStart application "${core.name}".\n\nIf you received this, your SMTP configuration is working correctly.`,
331
+ html: `<h2>ChadStart Test Email</h2><p>This is a test email from your ChadStart application <strong>&quot;${safeName}&quot;</strong>.</p><p>If you received this, your SMTP configuration is working correctly. &#x2705;</p>`,
332
+ });
333
+ res.json({ success: true, message: `Test email sent to ${to}` });
334
+ } catch (e) {
335
+ logger.error('Test email failed:', e.message);
336
+ res.status(502).json({ success: false, message: `Failed to send test email: ${e.message}` });
337
+ }
338
+ });
339
+
283
340
  // ── Admin AI assistant endpoints ──────────────────────────────────────
284
341
  // GET /admin/ai/status — tell the UI whether AI chat is available
285
342
  app.get('/admin/ai/status', adminRateLimiter, (_req, res) => {
@@ -387,6 +444,62 @@ async function buildApp(configPath, reloadFn) {
387
444
  }
388
445
  });
389
446
 
447
+ // ── Admin logs endpoint ─────────────────────────────────────────────
448
+ app.get('/admin/logs', adminRateLimiter, async (req, res) => {
449
+ const header = req.headers.authorization;
450
+ if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
451
+ try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
452
+ try {
453
+ const { method, statusCode, path: filterPath, from, to, page, perPage, order } = req.query;
454
+ const result = await queryLogs(
455
+ { method, statusCode, path: filterPath, from, to },
456
+ { page, perPage, order }
457
+ );
458
+ res.json(result);
459
+ } catch (err) {
460
+ res.status(500).json({ error: err.message });
461
+ }
462
+ });
463
+
464
+ // ── Admin backup endpoints ──────────────────────────────────────────
465
+ app.post('/admin/backup', adminRateLimiter, async (req, res) => {
466
+ const header = req.headers.authorization;
467
+ if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
468
+ try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
469
+ try {
470
+ const result = await createBackup(core.backup);
471
+ res.json(result);
472
+ } catch (err) {
473
+ res.status(500).json({ error: err.message });
474
+ }
475
+ });
476
+
477
+ app.post('/admin/restore', adminRateLimiter, async (req, res) => {
478
+ const header = req.headers.authorization;
479
+ if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
480
+ try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
481
+ const { file } = req.body || {};
482
+ if (!file || typeof file !== 'string') return res.status(400).json({ error: 'file (backup filename) is required' });
483
+ try {
484
+ const result = await restoreBackup(file, core.backup);
485
+ if (!result.success) return res.status(404).json(result);
486
+ res.json(result);
487
+ } catch (err) {
488
+ res.status(500).json({ error: err.message });
489
+ }
490
+ });
491
+
492
+ app.get('/admin/backups', adminRateLimiter, (req, res) => {
493
+ const header = req.headers.authorization;
494
+ if (!header || !header.startsWith('Bearer ')) return res.status(401).json({ error: 'Unauthorized' });
495
+ try { verifyToken(header.slice(7)); } catch { return res.status(401).json({ error: 'Invalid token' }); }
496
+ try {
497
+ res.json(listBackups(core.backup));
498
+ } catch (err) {
499
+ res.status(500).json({ error: err.message });
500
+ }
501
+ });
502
+
390
503
  // ── Admin seed endpoint ─────────────────────────────────────────────
391
504
  app.post('/admin/seed', adminRateLimiter, async (req, res) => {
392
505
  const header = req.headers.authorization;