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/TODO.md +739 -0
- package/chadstart.example.yaml +22 -0
- package/chadstart.schema.json +83 -0
- package/cli/cli.js +78 -0
- package/core/auth.js +160 -3
- package/core/backup.js +191 -0
- package/core/db.js +4 -0
- package/core/email.js +170 -0
- package/core/entity-engine.js +4 -0
- package/core/logs.js +179 -0
- package/core/openapi.js +6 -2
- package/package.json +2 -1
- package/server/express-server.js +113 -0
- package/test/backup.test.js +146 -0
- package/test/email.test.js +362 -0
- package/test/logs.test.js +239 -0
- package/test/verification.test.js +439 -0
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
|
+
};
|
package/core/entity-engine.js
CHANGED
|
@@ -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.
|
|
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",
|
package/server/express-server.js
CHANGED
|
@@ -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>"${safeName}"</strong>.</p><p>If you received this, your SMTP configuration is working correctly. ✅</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;
|