chadstart 1.0.4 → 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/Dockerfile +1 -1
- package/TODO.md +739 -0
- package/admin/index.html +27 -1
- package/{chadstart.example.yml → chadstart.example.yaml} +23 -1
- package/chadstart.schema.json +83 -0
- package/cli/cli.js +124 -35
- package/core/auth.js +160 -3
- package/core/backup.js +191 -0
- package/core/config-loader.js +266 -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/migrations.js +23 -18
- package/core/openapi.js +6 -2
- package/core/yaml-loader.js +8 -53
- package/docs/llm-rules.md +1 -1
- package/package.json +3 -1
- package/server/express-server.js +149 -18
- package/test/backup.test.js +146 -0
- package/test/config-loader.test.js +257 -0
- package/test/email.test.js +362 -0
- package/test/logs.test.js +239 -0
- package/test/verification.test.js +439 -0
package/core/db.js
CHANGED
|
@@ -235,6 +235,10 @@ function buildColumnDefs(entity, allEntities) {
|
|
|
235
235
|
if (entity.authenticable) {
|
|
236
236
|
cols.push({ name: 'email', def: `${q('email')} ${authStrType()} NOT NULL UNIQUE` });
|
|
237
237
|
cols.push({ name: 'password', def: `${q('password')} ${authStrType()} NOT NULL` });
|
|
238
|
+
cols.push({ name: 'emailVerified', def: `${q('emailVerified')} INTEGER DEFAULT 0` });
|
|
239
|
+
cols.push({ name: 'emailVerificationToken', def: `${q('emailVerificationToken')} TEXT` });
|
|
240
|
+
cols.push({ name: 'passwordResetToken', def: `${q('passwordResetToken')} TEXT` });
|
|
241
|
+
cols.push({ name: 'passwordResetExpiry', def: `${q('passwordResetExpiry')} TEXT` });
|
|
238
242
|
}
|
|
239
243
|
|
|
240
244
|
for (const p of entity.properties) {
|
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/migrations.js
CHANGED
|
@@ -8,16 +8,22 @@ const logger = require('../utils/logger');
|
|
|
8
8
|
|
|
9
9
|
const { buildCore, toSnakeCase } = require('./entity-engine');
|
|
10
10
|
const { DB_ENGINE, q, sqlType, idColType, authStrType } = require('./db');
|
|
11
|
+
const { detectFormat, parseRaw, loadConfig } = require('./config-loader');
|
|
11
12
|
|
|
12
13
|
// ─── Git helpers ──────────────────────────────────────────────────────────────
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
|
-
* Retrieve the last committed version of a file using git.
|
|
16
|
+
* Retrieve the last committed version of a config file using git.
|
|
16
17
|
* Returns null if the file has no committed history (brand-new / untracked).
|
|
17
18
|
*/
|
|
18
|
-
function
|
|
19
|
+
function getLastCommittedConfig(configPath) {
|
|
19
20
|
try {
|
|
20
|
-
const resolved = path.resolve(
|
|
21
|
+
const resolved = path.resolve(configPath);
|
|
22
|
+
const format = detectFormat(resolved);
|
|
23
|
+
|
|
24
|
+
// JS / Jsonnet configs can't be reconstructed from raw git content alone
|
|
25
|
+
if (format === 'js' || format === 'jsonnet') return null;
|
|
26
|
+
|
|
21
27
|
const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
22
28
|
cwd: path.dirname(resolved),
|
|
23
29
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -30,21 +36,17 @@ function getLastCommittedYaml(yamlPath) {
|
|
|
30
36
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
31
37
|
}).toString();
|
|
32
38
|
|
|
33
|
-
return
|
|
39
|
+
return parseRaw(raw, format);
|
|
34
40
|
} catch {
|
|
35
41
|
return null;
|
|
36
42
|
}
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
/**
|
|
40
|
-
* Load the current
|
|
46
|
+
* Load the current config file from disk and return the parsed object.
|
|
41
47
|
*/
|
|
42
|
-
function
|
|
43
|
-
|
|
44
|
-
if (!fs.existsSync(resolved)) {
|
|
45
|
-
throw new Error(`YAML config not found: ${resolved}`);
|
|
46
|
-
}
|
|
47
|
-
return YAML.parse(fs.readFileSync(resolved, 'utf8'));
|
|
48
|
+
function loadCurrentConfig(configPath) {
|
|
49
|
+
return loadConfig(configPath);
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
|
|
@@ -374,17 +376,17 @@ async function getMigrationStatus(migrationsDir, execQueryFn) {
|
|
|
374
376
|
// ─── High-level commands ──────────────────────────────────────────────────────
|
|
375
377
|
|
|
376
378
|
/**
|
|
377
|
-
* Generate a migration by diffing the current
|
|
379
|
+
* Generate a migration by diffing the current config against the last committed
|
|
378
380
|
* version in git. Writes numbered SQL files to the migrations directory.
|
|
379
381
|
*
|
|
380
|
-
* @param {string}
|
|
382
|
+
* @param {string} configPath Path to the config file (any supported format).
|
|
381
383
|
* @param {string} migrationsDir Path to the migrations directory.
|
|
382
384
|
* @param {string} [description] Optional description for the migration.
|
|
383
385
|
* @returns {{ doPath, undoPath, version, isEmpty } | null}
|
|
384
386
|
*/
|
|
385
|
-
function generateMigration(
|
|
386
|
-
const currentConfig =
|
|
387
|
-
const oldConfig =
|
|
387
|
+
function generateMigration(configPath, migrationsDir, description) {
|
|
388
|
+
const currentConfig = loadCurrentConfig(configPath);
|
|
389
|
+
const oldConfig = getLastCommittedConfig(configPath);
|
|
388
390
|
|
|
389
391
|
const newCore = buildCore(currentConfig);
|
|
390
392
|
const oldCore = oldConfig ? buildCore(oldConfig) : null;
|
|
@@ -408,8 +410,11 @@ function generateMigration(yamlPath, migrationsDir, description) {
|
|
|
408
410
|
|
|
409
411
|
module.exports = {
|
|
410
412
|
// Git helpers
|
|
411
|
-
|
|
412
|
-
|
|
413
|
+
getLastCommittedConfig,
|
|
414
|
+
loadCurrentConfig,
|
|
415
|
+
// Backward-compatible aliases
|
|
416
|
+
getLastCommittedYaml: getLastCommittedConfig,
|
|
417
|
+
loadCurrentYaml: loadCurrentConfig,
|
|
413
418
|
// Diff engine
|
|
414
419
|
diffCores,
|
|
415
420
|
// SQL generation
|
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/core/yaml-loader.js
CHANGED
|
@@ -1,64 +1,19 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const YAML = require('yaml');
|
|
6
|
-
const logger = require('../utils/logger');
|
|
7
|
-
|
|
8
3
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* Backward-compatible re-exports.
|
|
5
|
+
*
|
|
6
|
+
* All new code should import from `./config-loader` directly.
|
|
7
|
+
* These wrappers keep existing callers (tests, plugins) working unchanged.
|
|
11
8
|
*/
|
|
9
|
+
const { loadConfig, saveConfig } = require('./config-loader');
|
|
10
|
+
|
|
12
11
|
function loadYaml(filePath) {
|
|
13
|
-
|
|
14
|
-
if (!fs.existsSync(resolved)) {
|
|
15
|
-
throw new Error(`YAML config not found: ${resolved}`);
|
|
16
|
-
}
|
|
17
|
-
const raw = fs.readFileSync(resolved, 'utf8');
|
|
18
|
-
const parsed = YAML.parse(raw);
|
|
19
|
-
logger.debug('Loaded YAML from', resolved);
|
|
20
|
-
return parsed;
|
|
12
|
+
return loadConfig(filePath);
|
|
21
13
|
}
|
|
22
14
|
|
|
23
|
-
/**
|
|
24
|
-
* Save an updated config object back to a YAML file.
|
|
25
|
-
* Uses yaml's Document API so that comments in unchanged top-level sections
|
|
26
|
-
* are preserved as much as possible.
|
|
27
|
-
*
|
|
28
|
-
* @param {string} filePath Path to the YAML file.
|
|
29
|
-
* @param {object} config Plain-JS config object (already validated).
|
|
30
|
-
*/
|
|
31
15
|
function saveYaml(filePath, config) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
let doc;
|
|
35
|
-
if (fs.existsSync(resolved)) {
|
|
36
|
-
// Parse into a live Document to keep comments / blank lines on unchanged nodes
|
|
37
|
-
const raw = fs.readFileSync(resolved, 'utf8');
|
|
38
|
-
doc = YAML.parseDocument(raw);
|
|
39
|
-
|
|
40
|
-
const existing = doc.toJS() || {};
|
|
41
|
-
const existingKeys = Object.keys(existing);
|
|
42
|
-
const newKeys = Object.keys(config);
|
|
43
|
-
|
|
44
|
-
// Update or add every key from the incoming config
|
|
45
|
-
for (const key of newKeys) {
|
|
46
|
-
doc.set(key, config[key]);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Remove top-level keys that are no longer present
|
|
50
|
-
for (const key of existingKeys) {
|
|
51
|
-
if (!newKeys.includes(key)) {
|
|
52
|
-
doc.delete(key);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
} else {
|
|
56
|
-
// Create a fresh Document when the file does not yet exist
|
|
57
|
-
doc = new YAML.Document(config);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
fs.writeFileSync(resolved, doc.toString(), 'utf8');
|
|
61
|
-
logger.debug('Saved YAML to', resolved);
|
|
16
|
+
return saveConfig(filePath, config);
|
|
62
17
|
}
|
|
63
18
|
|
|
64
19
|
module.exports = { loadYaml, saveYaml };
|
package/docs/llm-rules.md
CHANGED
|
@@ -75,7 +75,7 @@ If you already have an existing project and want to add AI rules manually, creat
|
|
|
75
75
|
| **GitHub Copilot** | `.github/copilot-instructions.md` |
|
|
76
76
|
| **Windsurf** | `.windsurf/rules/chadstart.md` |
|
|
77
77
|
|
|
78
|
-
You can base your rules file on the [chadstart.example.
|
|
78
|
+
You can base your rules file on the [chadstart.example.yaml](https://github.com/saulmmendoza/chadstart.com/blob/main/chadstart.example.yaml) reference file, which documents every available configuration option.
|
|
79
79
|
|
|
80
80
|
!!! tip
|
|
81
81
|
Keep your rules file up to date whenever you upgrade ChadStart. Newer versions may introduce new field types, options, or top-level blocks that your AI assistant won't know about unless the rules file is updated.
|
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": {
|
|
@@ -49,9 +49,11 @@
|
|
|
49
49
|
"express-rate-limit": "^8.3.1",
|
|
50
50
|
"grant": "^5.4.24",
|
|
51
51
|
"htmx.org": "2.0.4",
|
|
52
|
+
"json5": "^2.2.3",
|
|
52
53
|
"jsonwebtoken": "^9.0.3",
|
|
53
54
|
"mysql2": "^3.20.0",
|
|
54
55
|
"node-cron": "^4.2.1",
|
|
56
|
+
"nodemailer": "^8.0.4",
|
|
55
57
|
"pg": "^8.20.0",
|
|
56
58
|
"postgrator": "^8.0.0",
|
|
57
59
|
"sharp": "^0.34.5",
|