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/chadstart.example.yaml
CHANGED
|
@@ -415,6 +415,28 @@ sentry:
|
|
|
415
415
|
tracesSampleRate: 1.0 # Fraction of transactions to sample (0.0–1.0)
|
|
416
416
|
debug: false # Enable Sentry SDK debug logging
|
|
417
417
|
|
|
418
|
+
# ── Email / SMTP ──────────────────────────────────────────────────────────────
|
|
419
|
+
# Configure SMTP for transactional emails (verification, password reset, etc.).
|
|
420
|
+
# The SMTP password is a secret and must be provided via the SMTP_PASS env var.
|
|
421
|
+
# All fields can be overridden by environment variables:
|
|
422
|
+
# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM
|
|
423
|
+
|
|
424
|
+
email:
|
|
425
|
+
host: smtp.example.com # SMTP server hostname. Overridable via SMTP_HOST
|
|
426
|
+
port: 587 # SMTP port (587=STARTTLS, 465=SSL, 25=plain). Overridable via SMTP_PORT
|
|
427
|
+
username: noreply@example.com # SMTP login. Overridable via SMTP_USER
|
|
428
|
+
from: "My App <noreply@example.com>" # Default sender. Overridable via SMTP_FROM
|
|
429
|
+
secure: false # Use TLS (auto-detected from port if omitted)
|
|
430
|
+
templates:
|
|
431
|
+
verification:
|
|
432
|
+
subject: "Verify your email for {{appName}}"
|
|
433
|
+
text: "Hi {{name}},\n\nPlease verify your email by visiting:\n{{link}}\n\nThanks,\n{{appName}}"
|
|
434
|
+
html: "<h2>Verify your email</h2><p>Hi {{name}},</p><p>Please verify your email by clicking the link below:</p><p><a href=\"{{link}}\">Verify Email</a></p><p>Thanks,<br>{{appName}}</p>"
|
|
435
|
+
passwordReset:
|
|
436
|
+
subject: "Reset your password for {{appName}}"
|
|
437
|
+
text: "Hi {{name}},\n\nYou requested a password reset. Visit:\n{{link}}\n\nIf you didn't request this, ignore this email.\n\nThanks,\n{{appName}}"
|
|
438
|
+
html: "<h2>Reset your password</h2><p>Hi {{name}},</p><p>You requested a password reset. Click the link below:</p><p><a href=\"{{link}}\">Reset Password</a></p><p>If you didn't request this, ignore this email.</p><p>Thanks,<br>{{appName}}</p>"
|
|
439
|
+
|
|
418
440
|
# ── OAuth / Social Login ─────────────────────────────────────────────────────
|
|
419
441
|
# Powered by the "grant" library — supports 200+ OAuth providers.
|
|
420
442
|
# Secrets (client keys / secrets) MUST be set via environment variables:
|
package/chadstart.schema.json
CHANGED
|
@@ -125,6 +125,78 @@
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
},
|
|
128
|
+
"logs": {
|
|
129
|
+
"type": "object",
|
|
130
|
+
"description": "Request logging configuration. Logs are stored in a _cs_logs system table and accessible via GET /admin/logs.",
|
|
131
|
+
"additionalProperties": false,
|
|
132
|
+
"properties": {
|
|
133
|
+
"retention": {
|
|
134
|
+
"type": "integer",
|
|
135
|
+
"default": 30,
|
|
136
|
+
"description": "Number of days to keep log entries. 0 = keep forever. Default: 30."
|
|
137
|
+
},
|
|
138
|
+
"exclude": {
|
|
139
|
+
"type": "array",
|
|
140
|
+
"items": { "type": "string" },
|
|
141
|
+
"description": "URL path prefixes to exclude from logging (e.g. ['/health', '/admin/vendor'])."
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
"backup": {
|
|
146
|
+
"type": "object",
|
|
147
|
+
"description": "Database backup configuration. Backups are created via POST /admin/backup or `npx chadstart backup`.",
|
|
148
|
+
"additionalProperties": false,
|
|
149
|
+
"properties": {
|
|
150
|
+
"dir": {
|
|
151
|
+
"type": "string",
|
|
152
|
+
"default": "backups",
|
|
153
|
+
"description": "Directory to store backup files. Overridable via BACKUP_DIR env var. Default: backups."
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
"email": {
|
|
158
|
+
"type": "object",
|
|
159
|
+
"description": "Email / SMTP configuration for sending transactional emails (verification, password reset, notifications). The SMTP password must be supplied via the SMTP_PASS environment variable — never put it here. All fields can be overridden by SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_FROM env vars.",
|
|
160
|
+
"additionalProperties": false,
|
|
161
|
+
"properties": {
|
|
162
|
+
"host": {
|
|
163
|
+
"type": "string",
|
|
164
|
+
"description": "SMTP server hostname (e.g. smtp.gmail.com). Overridable via SMTP_HOST env var."
|
|
165
|
+
},
|
|
166
|
+
"port": {
|
|
167
|
+
"type": "integer",
|
|
168
|
+
"default": 587,
|
|
169
|
+
"description": "SMTP server port. Common values: 587 (STARTTLS), 465 (SSL/TLS), 25 (unencrypted). Overridable via SMTP_PORT env var."
|
|
170
|
+
},
|
|
171
|
+
"username": {
|
|
172
|
+
"type": "string",
|
|
173
|
+
"description": "SMTP login username. Overridable via SMTP_USER env var."
|
|
174
|
+
},
|
|
175
|
+
"from": {
|
|
176
|
+
"type": "string",
|
|
177
|
+
"description": "Default sender address (e.g. 'My App <noreply@example.com>'). Overridable via SMTP_FROM env var."
|
|
178
|
+
},
|
|
179
|
+
"secure": {
|
|
180
|
+
"type": "boolean",
|
|
181
|
+
"description": "Use TLS when connecting to the server. Defaults to true for port 465, false otherwise."
|
|
182
|
+
},
|
|
183
|
+
"templates": {
|
|
184
|
+
"type": "object",
|
|
185
|
+
"description": "Email templates with {{variable}} placeholders. Used by built-in flows (verification, password reset).",
|
|
186
|
+
"additionalProperties": false,
|
|
187
|
+
"properties": {
|
|
188
|
+
"verification": {
|
|
189
|
+
"$ref": "#/$defs/emailTemplate",
|
|
190
|
+
"description": "Template for email verification messages."
|
|
191
|
+
},
|
|
192
|
+
"passwordReset": {
|
|
193
|
+
"$ref": "#/$defs/emailTemplate",
|
|
194
|
+
"description": "Template for password reset messages."
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
},
|
|
128
200
|
"oauth": {
|
|
129
201
|
"type": "object",
|
|
130
202
|
"description": "OAuth / social login configuration powered by the grant library. Secrets (client keys and secrets) must be supplied via OAUTH_<PROVIDER>_KEY and OAUTH_<PROVIDER>_SECRET environment variables.",
|
|
@@ -292,6 +364,7 @@
|
|
|
292
364
|
"type": "object",
|
|
293
365
|
"properties": {
|
|
294
366
|
"authenticable": { "type": "boolean", "default": false, "description": "Makes this entity authenticable (adds email + password fields, enables login/signup)." },
|
|
367
|
+
"requireEmailVerification": { "type": "boolean", "default": false, "description": "When true, login is blocked until the user verifies their email address." },
|
|
295
368
|
"single": { "type": "boolean", "default": false, "description": "Single entity — only one record exists (no create/delete)." },
|
|
296
369
|
"mainProp": { "type": "string", "description": "Identifier property used in the admin panel." },
|
|
297
370
|
"nameSingular": { "type": "string" },
|
|
@@ -400,6 +473,16 @@
|
|
|
400
473
|
"ttl": { "type": "integer", "description": "Time window in milliseconds." }
|
|
401
474
|
}
|
|
402
475
|
},
|
|
476
|
+
"emailTemplate": {
|
|
477
|
+
"type": "object",
|
|
478
|
+
"description": "An email template with subject, text body, and optional HTML body. Supports {{variable}} placeholders.",
|
|
479
|
+
"additionalProperties": false,
|
|
480
|
+
"properties": {
|
|
481
|
+
"subject": { "type": "string", "description": "Email subject line. Supports {{appName}}, {{name}}, {{link}} placeholders." },
|
|
482
|
+
"text": { "type": "string", "description": "Plain-text email body. Supports {{appName}}, {{name}}, {{link}} placeholders." },
|
|
483
|
+
"html": { "type": "string", "description": "HTML email body. Supports {{appName}}, {{name}}, {{link}} placeholders." }
|
|
484
|
+
}
|
|
485
|
+
},
|
|
403
486
|
"oauthProvider": {
|
|
404
487
|
"type": "object",
|
|
405
488
|
"description": "Configuration for a single OAuth provider. The key and secret should be set via OAUTH_<PROVIDER>_KEY and OAUTH_<PROVIDER>_SECRET environment variables.",
|
package/cli/cli.js
CHANGED
|
@@ -21,6 +21,8 @@ Usage:
|
|
|
21
21
|
npx chadstart migrate Run pending database migrations
|
|
22
22
|
npx chadstart migrate:generate Generate migration from config diff (git-based)
|
|
23
23
|
npx chadstart migrate:status Show current migration status
|
|
24
|
+
npx chadstart backup Create a database backup
|
|
25
|
+
npx chadstart restore <file> Restore database from a backup file
|
|
24
26
|
|
|
25
27
|
Options:
|
|
26
28
|
--config <file> Path to config file (default: auto-discover)
|
|
@@ -77,6 +79,10 @@ if (command === 'create') {
|
|
|
77
79
|
runMigrateGenerate();
|
|
78
80
|
} else if (command === 'migrate:status') {
|
|
79
81
|
runMigrateStatus();
|
|
82
|
+
} else if (command === 'backup') {
|
|
83
|
+
runBackup();
|
|
84
|
+
} else if (command === 'restore') {
|
|
85
|
+
runRestore();
|
|
80
86
|
} else {
|
|
81
87
|
console.error(`Unknown command: ${command}`);
|
|
82
88
|
printUsage();
|
|
@@ -413,6 +419,78 @@ async function runMigrateStatus() {
|
|
|
413
419
|
|
|
414
420
|
// ─── Other helpers ───────────────────────────────────────────────────────────
|
|
415
421
|
|
|
422
|
+
async function runBackup() {
|
|
423
|
+
if (!fs.existsSync(configPath)) {
|
|
424
|
+
console.error(`Config not found: ${configPath}`);
|
|
425
|
+
process.exit(1);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
const { loadConfig } = require('../core/config-loader');
|
|
430
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
431
|
+
const { buildCore } = require('../core/entity-engine');
|
|
432
|
+
const { initDb, closeDb } = require('../core/db');
|
|
433
|
+
const { createBackup } = require('../core/backup');
|
|
434
|
+
|
|
435
|
+
const config = loadConfig(configPath);
|
|
436
|
+
validateSchema(config);
|
|
437
|
+
const core = buildCore(config);
|
|
438
|
+
await initDb(core);
|
|
439
|
+
|
|
440
|
+
console.log('\n💾 Creating backup...\n');
|
|
441
|
+
const result = await createBackup(core.backup);
|
|
442
|
+
console.log(` ✅ Backup created: ${result.file}`);
|
|
443
|
+
console.log(` Path: ${result.path}`);
|
|
444
|
+
console.log(` Size: ${(result.size / 1024).toFixed(1)} KB`);
|
|
445
|
+
console.log(` Engine: ${result.engine}\n`);
|
|
446
|
+
|
|
447
|
+
await closeDb();
|
|
448
|
+
} catch (err) {
|
|
449
|
+
console.error(`\n❌ ${err.message}\n`);
|
|
450
|
+
process.exit(1);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function runRestore() {
|
|
455
|
+
const backupFile = args[1];
|
|
456
|
+
if (!backupFile) {
|
|
457
|
+
console.error('Error: backup file name is required. Usage: npx chadstart restore <file>');
|
|
458
|
+
process.exit(1);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (!fs.existsSync(configPath)) {
|
|
462
|
+
console.error(`Config not found: ${configPath}`);
|
|
463
|
+
process.exit(1);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const { loadConfig } = require('../core/config-loader');
|
|
468
|
+
const { validateSchema } = require('../core/schema-validator');
|
|
469
|
+
const { buildCore } = require('../core/entity-engine');
|
|
470
|
+
const { initDb } = require('../core/db');
|
|
471
|
+
const { restoreBackup } = require('../core/backup');
|
|
472
|
+
|
|
473
|
+
const config = loadConfig(configPath);
|
|
474
|
+
validateSchema(config);
|
|
475
|
+
const core = buildCore(config);
|
|
476
|
+
await initDb(core);
|
|
477
|
+
|
|
478
|
+
console.log(`\n🔄 Restoring from ${backupFile}...\n`);
|
|
479
|
+
const result = await restoreBackup(backupFile, core.backup);
|
|
480
|
+
if (result.success) {
|
|
481
|
+
console.log(` ✅ ${result.message}\n`);
|
|
482
|
+
} else {
|
|
483
|
+
console.error(` ❌ ${result.message}\n`);
|
|
484
|
+
process.exit(1);
|
|
485
|
+
}
|
|
486
|
+
} catch (err) {
|
|
487
|
+
console.error(`\n❌ ${err.message}\n`);
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ─── Other helpers ───────────────────────────────────────────────────────────
|
|
493
|
+
|
|
416
494
|
function applyPortOverride() {
|
|
417
495
|
if (portOverride) {
|
|
418
496
|
process.env.CHADSTART_PORT = portOverride;
|
package/core/auth.js
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* POST /api/auth/:slug/signup
|
|
8
8
|
* POST /api/auth/:slug/login
|
|
9
9
|
* GET /api/auth/:slug/me
|
|
10
|
+
* POST /api/auth/:slug/request-verification
|
|
11
|
+
* POST /api/auth/:slug/confirm-verification
|
|
12
|
+
* POST /api/auth/:slug/request-password-reset
|
|
13
|
+
* POST /api/auth/:slug/confirm-password-reset
|
|
10
14
|
* GET /api/auth/:slug/api-keys
|
|
11
15
|
* POST /api/auth/:slug/api-keys
|
|
12
16
|
* DELETE /api/auth/:slug/api-keys/:id
|
|
@@ -42,6 +46,25 @@ function signToken(payload, expiresIn) {
|
|
|
42
46
|
}
|
|
43
47
|
function verifyToken(token) { return jwt.verify(token, JWT_SECRET); }
|
|
44
48
|
|
|
49
|
+
/** Generate a cryptographically secure random hex token. */
|
|
50
|
+
function generateSecureToken() {
|
|
51
|
+
return crypto.randomBytes(32).toString('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Default email templates ─────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const DEFAULT_VERIFICATION_TEMPLATE = {
|
|
57
|
+
subject: 'Verify your email for {{appName}}',
|
|
58
|
+
text: 'Hi {{name}},\n\nPlease verify your email address by using the following token:\n\n{{token}}\n\nOr click this link: {{link}}\n\nThanks,\n{{appName}}',
|
|
59
|
+
html: '<h2>Verify your email</h2><p>Hi {{name}},</p><p>Please verify your email address by clicking the link below:</p><p><a href="{{link}}">Verify Email</a></p><p>Or use this token: <code>{{token}}</code></p><p>Thanks,<br>{{appName}}</p>',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const DEFAULT_PASSWORD_RESET_TEMPLATE = {
|
|
63
|
+
subject: 'Reset your password for {{appName}}',
|
|
64
|
+
text: 'Hi {{name}},\n\nYou requested a password reset. Use the following token:\n\n{{token}}\n\nOr click this link: {{link}}\n\nThis link expires in 1 hour.\n\nIf you did not request this, please ignore this email.\n\nThanks,\n{{appName}}',
|
|
65
|
+
html: '<h2>Reset your password</h2><p>Hi {{name}},</p><p>You requested a password reset. Click the link below:</p><p><a href="{{link}}">Reset Password</a></p><p>Or use this token: <code>{{token}}</code></p><p>This link expires in 1 hour.</p><p>If you did not request this, please ignore this email.</p><p>Thanks,<br>{{appName}}</p>',
|
|
66
|
+
};
|
|
67
|
+
|
|
45
68
|
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
|
46
69
|
|
|
47
70
|
/**
|
|
@@ -231,6 +254,34 @@ function registerApiKeyRoutes(app, core) {
|
|
|
231
254
|
|
|
232
255
|
function registerAuthRoutes(app, core, emit) {
|
|
233
256
|
const _emit = typeof emit === 'function' ? emit : () => {};
|
|
257
|
+
|
|
258
|
+
// Lazily load email module to avoid circular dependency at module load time
|
|
259
|
+
let _emailMod;
|
|
260
|
+
function _getEmail() {
|
|
261
|
+
if (!_emailMod) _emailMod = require('./email');
|
|
262
|
+
return _emailMod;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Try to send an email — logs and swallows errors so auth flow still succeeds when SMTP is down. */
|
|
266
|
+
async function _trySend(opts) {
|
|
267
|
+
try {
|
|
268
|
+
await _getEmail().sendEmail(opts);
|
|
269
|
+
} catch (e) {
|
|
270
|
+
logger.warn(`Email send failed (${opts.to}): ${e.message}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Merge user-defined template with built-in default. */
|
|
275
|
+
function _tpl(kind) {
|
|
276
|
+
const defaults = kind === 'verification' ? DEFAULT_VERIFICATION_TEMPLATE : DEFAULT_PASSWORD_RESET_TEMPLATE;
|
|
277
|
+
const custom = ((core.email || {}).templates || {})[kind] || {};
|
|
278
|
+
return {
|
|
279
|
+
subject: custom.subject || defaults.subject,
|
|
280
|
+
text: custom.text || defaults.text,
|
|
281
|
+
html: custom.html || defaults.html,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
234
285
|
for (const entity of Object.values(core.authenticableEntities || {})) {
|
|
235
286
|
const slug = entity.slug;
|
|
236
287
|
const table = entity.tableName;
|
|
@@ -240,34 +291,135 @@ function registerAuthRoutes(app, core, emit) {
|
|
|
240
291
|
const signupPolicies = (entity.policies || {}).signup;
|
|
241
292
|
const signupForbidden = signupPolicies && signupPolicies.length > 0 && signupPolicies[0].access === 'forbidden';
|
|
242
293
|
|
|
294
|
+
// ── Signup ────────────────────────────────────────────────────────────
|
|
243
295
|
app.post(`/api/auth/${slug}/signup`, async (req, res) => {
|
|
244
296
|
try {
|
|
245
297
|
if (signupForbidden) return res.status(403).json({ error: 'Signup is forbidden for this entity' });
|
|
246
298
|
const { email, password, ...rest } = req.body || {};
|
|
247
299
|
if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
|
|
248
300
|
if ((await db.findAllSimple(table, { email })).length) return res.status(409).json({ error: 'Email already registered' });
|
|
249
|
-
|
|
301
|
+
|
|
302
|
+
// Generate email verification token
|
|
303
|
+
const verificationToken = generateSecureToken();
|
|
304
|
+
const user = await db.create(table, {
|
|
305
|
+
email,
|
|
306
|
+
password: await bcrypt.hash(password, BCRYPT_ROUNDS),
|
|
307
|
+
emailVerified: 0,
|
|
308
|
+
emailVerificationToken: verificationToken,
|
|
309
|
+
...sanitize(rest),
|
|
310
|
+
});
|
|
250
311
|
_emit(`${entity.name}.created`, omitPassword(user));
|
|
312
|
+
|
|
313
|
+
// Send verification email (best-effort — does not block signup)
|
|
314
|
+
const tpl = _tpl('verification');
|
|
315
|
+
const vars = { appName: core.name, name: email, token: verificationToken, link: `${_appUrl()}/verify?token=${verificationToken}` };
|
|
316
|
+
_trySend({ to: email, subject: tpl.subject, text: tpl.text, html: tpl.html, vars });
|
|
317
|
+
|
|
251
318
|
res.status(201).json({ token: signToken({ id: user.id, entity: entity.name }), user: omitPassword(user) });
|
|
252
319
|
} catch (e) { logger.error('signup error', e.message); res.status(500).json({ error: e.message }); }
|
|
253
320
|
});
|
|
254
321
|
|
|
322
|
+
// ── Login ─────────────────────────────────────────────────────────────
|
|
255
323
|
app.post(`/api/auth/${slug}/login`, async (req, res) => {
|
|
256
324
|
try {
|
|
257
325
|
const { email, password } = req.body || {};
|
|
258
326
|
if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
|
|
259
327
|
const user = (await db.findAllSimple(table, { email }))[0];
|
|
260
328
|
if (!user || !(await bcrypt.compare(password, user.password))) return res.status(401).json({ error: 'Invalid credentials' });
|
|
329
|
+
|
|
330
|
+
// Block login if email verification is required but not done
|
|
331
|
+
if (entity.requireEmailVerification && !user.emailVerified) {
|
|
332
|
+
return res.status(403).json({ error: 'Email not verified. Please verify your email before logging in.' });
|
|
333
|
+
}
|
|
334
|
+
|
|
261
335
|
res.json({ token: signToken({ id: user.id, entity: entity.name }), user: omitPassword(user) });
|
|
262
336
|
} catch (e) { logger.error('login error', e.message); res.status(500).json({ error: e.message }); }
|
|
263
337
|
});
|
|
264
338
|
|
|
339
|
+
// ── Me ─────────────────────────────────────────────────────────────────
|
|
265
340
|
app.get(`/api/auth/${slug}/me`, requireAuth(entity.name), async (req, res) => {
|
|
266
341
|
const user = await db.findById(table, req.user.id);
|
|
267
342
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
268
343
|
res.json(omitPassword(user));
|
|
269
344
|
});
|
|
270
345
|
|
|
346
|
+
// ── Request Verification ──────────────────────────────────────────────
|
|
347
|
+
app.post(`/api/auth/${slug}/request-verification`, requireAuth(entity.name), async (req, res) => {
|
|
348
|
+
try {
|
|
349
|
+
const user = await db.findById(table, req.user.id);
|
|
350
|
+
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
351
|
+
if (user.emailVerified) return res.json({ message: 'Email already verified' });
|
|
352
|
+
|
|
353
|
+
const token = generateSecureToken();
|
|
354
|
+
await db.update(table, user.id, { emailVerificationToken: token });
|
|
355
|
+
|
|
356
|
+
const tpl = _tpl('verification');
|
|
357
|
+
const vars = { appName: core.name, name: user.email, token, link: `${_appUrl()}/verify?token=${token}` };
|
|
358
|
+
await _trySend({ to: user.email, subject: tpl.subject, text: tpl.text, html: tpl.html, vars });
|
|
359
|
+
|
|
360
|
+
res.json({ message: 'Verification email sent' });
|
|
361
|
+
} catch (e) { logger.error('request-verification error', e.message); res.status(500).json({ error: e.message }); }
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ── Confirm Verification ──────────────────────────────────────────────
|
|
365
|
+
app.post(`/api/auth/${slug}/confirm-verification`, async (req, res) => {
|
|
366
|
+
try {
|
|
367
|
+
const { token } = req.body || {};
|
|
368
|
+
if (!token) return res.status(400).json({ error: 'token is required' });
|
|
369
|
+
|
|
370
|
+
const user = (await db.findAllSimple(table, { emailVerificationToken: token }))[0];
|
|
371
|
+
if (!user) return res.status(400).json({ error: 'Invalid or expired verification token' });
|
|
372
|
+
|
|
373
|
+
await db.update(table, user.id, { emailVerified: 1, emailVerificationToken: null });
|
|
374
|
+
res.json({ message: 'Email verified successfully' });
|
|
375
|
+
} catch (e) { logger.error('confirm-verification error', e.message); res.status(500).json({ error: e.message }); }
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ── Request Password Reset ────────────────────────────────────────────
|
|
379
|
+
app.post(`/api/auth/${slug}/request-password-reset`, async (req, res) => {
|
|
380
|
+
try {
|
|
381
|
+
const { email } = req.body || {};
|
|
382
|
+
if (!email) return res.status(400).json({ error: 'email is required' });
|
|
383
|
+
|
|
384
|
+
// Always return 200 to avoid leaking whether email exists
|
|
385
|
+
const user = (await db.findAllSimple(table, { email }))[0];
|
|
386
|
+
if (user) {
|
|
387
|
+
const token = generateSecureToken();
|
|
388
|
+
const expiry = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
|
|
389
|
+
await db.update(table, user.id, { passwordResetToken: token, passwordResetExpiry: expiry });
|
|
390
|
+
|
|
391
|
+
const tpl = _tpl('passwordReset');
|
|
392
|
+
const vars = { appName: core.name, name: user.email, token, link: `${_appUrl()}/reset-password?token=${token}` };
|
|
393
|
+
_trySend({ to: user.email, subject: tpl.subject, text: tpl.text, html: tpl.html, vars });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
res.json({ message: 'If an account with that email exists, a password reset email has been sent.' });
|
|
397
|
+
} catch (e) { logger.error('request-password-reset error', e.message); res.status(500).json({ error: e.message }); }
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ── Confirm Password Reset ────────────────────────────────────────────
|
|
401
|
+
app.post(`/api/auth/${slug}/confirm-password-reset`, async (req, res) => {
|
|
402
|
+
try {
|
|
403
|
+
const { token, password } = req.body || {};
|
|
404
|
+
if (!token || !password) return res.status(400).json({ error: 'token and password are required' });
|
|
405
|
+
|
|
406
|
+
const user = (await db.findAllSimple(table, { passwordResetToken: token }))[0];
|
|
407
|
+
if (!user) return res.status(400).json({ error: 'Invalid or expired reset token' });
|
|
408
|
+
|
|
409
|
+
// Check expiry
|
|
410
|
+
if (!user.passwordResetExpiry || new Date(user.passwordResetExpiry) < new Date()) {
|
|
411
|
+
return res.status(400).json({ error: 'Reset token has expired' });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
await db.update(table, user.id, {
|
|
415
|
+
password: await bcrypt.hash(password, BCRYPT_ROUNDS),
|
|
416
|
+
passwordResetToken: null,
|
|
417
|
+
passwordResetExpiry: null,
|
|
418
|
+
});
|
|
419
|
+
res.json({ message: 'Password reset successfully' });
|
|
420
|
+
} catch (e) { logger.error('confirm-password-reset error', e.message); res.status(500).json({ error: e.message }); }
|
|
421
|
+
});
|
|
422
|
+
|
|
271
423
|
logger.info(` Registered auth routes at /api/auth/${slug}/`);
|
|
272
424
|
}
|
|
273
425
|
}
|
|
@@ -294,13 +446,18 @@ async function optionalAuth(req, _res, next) {
|
|
|
294
446
|
}
|
|
295
447
|
|
|
296
448
|
function omitPassword(user) {
|
|
297
|
-
const { password: _, ...rest } = user;
|
|
449
|
+
const { password: _, emailVerificationToken: _2, passwordResetToken: _3, passwordResetExpiry: _4, ...rest } = user;
|
|
298
450
|
return rest;
|
|
299
451
|
}
|
|
300
452
|
|
|
453
|
+
/** Derive the app's base URL for email links. */
|
|
454
|
+
function _appUrl() {
|
|
455
|
+
return process.env.APP_URL || process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
|
456
|
+
}
|
|
457
|
+
|
|
301
458
|
module.exports = {
|
|
302
459
|
registerAuthRoutes, registerApiKeyRoutes, initApiKeys,
|
|
303
460
|
requireAuth, optionalAuth, resolveAuthHeader,
|
|
304
|
-
signToken, verifyToken, omitPassword, JWT_SECRET,
|
|
461
|
+
signToken, verifyToken, omitPassword, JWT_SECRET, generateSecureToken,
|
|
305
462
|
createApiKey, listApiKeys, listAllApiKeys, deleteApiKey, verifyApiKeyStr,
|
|
306
463
|
};
|
package/core/backup.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Backup & Restore module for ChadStart.
|
|
5
|
+
*
|
|
6
|
+
* Supports SQLite (file copy), PostgreSQL (pg_dump), and MySQL (mysqldump).
|
|
7
|
+
*
|
|
8
|
+
* Configuration via YAML `backup` section:
|
|
9
|
+
* backup:
|
|
10
|
+
* dir: backups # Directory for backup files (default: backups)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const { execFileSync } = require('child_process');
|
|
17
|
+
const db = require('./db');
|
|
18
|
+
const logger = require('../utils/logger');
|
|
19
|
+
|
|
20
|
+
const DB_ENGINE = db.DB_ENGINE;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get the backup directory. Creates it if it doesn't exist.
|
|
24
|
+
*
|
|
25
|
+
* @param {object|null} backupCfg Value of `core.backup` (may be null).
|
|
26
|
+
* @returns {string} Absolute path to backup directory.
|
|
27
|
+
*/
|
|
28
|
+
function getBackupDir(backupCfg) {
|
|
29
|
+
const dir = (backupCfg && backupCfg.dir) || process.env.BACKUP_DIR || 'backups';
|
|
30
|
+
const resolved = path.resolve(dir);
|
|
31
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
32
|
+
return resolved;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Generate a backup filename with timestamp.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} ext File extension (e.g. 'db', 'sql').
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function generateBackupName(ext) {
|
|
42
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
43
|
+
const id = crypto.randomBytes(4).toString('hex');
|
|
44
|
+
return `backup-${ts}-${id}.${ext}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create a backup of the database.
|
|
49
|
+
*
|
|
50
|
+
* @param {object|null} backupCfg Value of `core.backup` (may be null).
|
|
51
|
+
* @returns {{ file: string, path: string, size: number, engine: string }}
|
|
52
|
+
*/
|
|
53
|
+
async function createBackup(backupCfg) {
|
|
54
|
+
const dir = getBackupDir(backupCfg);
|
|
55
|
+
|
|
56
|
+
if (DB_ENGINE === 'sqlite') {
|
|
57
|
+
const sqliteDb = db.getDb();
|
|
58
|
+
const name = generateBackupName('db');
|
|
59
|
+
const dest = path.join(dir, name);
|
|
60
|
+
await sqliteDb.backup(dest);
|
|
61
|
+
const stats = fs.statSync(dest);
|
|
62
|
+
logger.info(`Backup created: ${dest} (${stats.size} bytes)`);
|
|
63
|
+
return { file: name, path: dest, size: stats.size, engine: 'sqlite' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (DB_ENGINE === 'postgres') {
|
|
67
|
+
const name = generateBackupName('sql');
|
|
68
|
+
const dest = path.join(dir, name);
|
|
69
|
+
const args = [
|
|
70
|
+
'-h', process.env.DB_HOST || 'localhost',
|
|
71
|
+
'-p', process.env.DB_PORT || '5432',
|
|
72
|
+
'-U', process.env.DB_USERNAME || 'postgres',
|
|
73
|
+
'-d', process.env.DB_DATABASE || 'manifest',
|
|
74
|
+
'-f', dest,
|
|
75
|
+
];
|
|
76
|
+
execFileSync('pg_dump', args, {
|
|
77
|
+
env: { ...process.env, PGPASSWORD: process.env.DB_PASSWORD || 'postgres' },
|
|
78
|
+
timeout: 120000,
|
|
79
|
+
});
|
|
80
|
+
const stats = fs.statSync(dest);
|
|
81
|
+
logger.info(`Backup created: ${dest} (${stats.size} bytes)`);
|
|
82
|
+
return { file: name, path: dest, size: stats.size, engine: 'postgres' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (DB_ENGINE === 'mysql') {
|
|
86
|
+
const name = generateBackupName('sql');
|
|
87
|
+
const dest = path.join(dir, name);
|
|
88
|
+
const args = [
|
|
89
|
+
'-h', process.env.DB_HOST || 'localhost',
|
|
90
|
+
'-P', process.env.DB_PORT || '3306',
|
|
91
|
+
'-u', process.env.DB_USERNAME || 'root',
|
|
92
|
+
`--result-file=${dest}`,
|
|
93
|
+
process.env.DB_DATABASE || 'manifest',
|
|
94
|
+
];
|
|
95
|
+
const env = { ...process.env };
|
|
96
|
+
if (process.env.DB_PASSWORD) env.MYSQL_PWD = process.env.DB_PASSWORD;
|
|
97
|
+
execFileSync('mysqldump', args, { env, timeout: 120000 });
|
|
98
|
+
const stats = fs.statSync(dest);
|
|
99
|
+
logger.info(`Backup created: ${dest} (${stats.size} bytes)`);
|
|
100
|
+
return { file: name, path: dest, size: stats.size, engine: 'mysql' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
throw new Error(`Unsupported database engine for backup: ${DB_ENGINE}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Restore a database from a backup file.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} backupFile Filename of the backup (relative to backup dir).
|
|
110
|
+
* @param {object|null} backupCfg Value of `core.backup` (may be null).
|
|
111
|
+
* @returns {{ success: boolean, message: string }}
|
|
112
|
+
*/
|
|
113
|
+
async function restoreBackup(backupFile, backupCfg) {
|
|
114
|
+
const dir = getBackupDir(backupCfg);
|
|
115
|
+
const src = path.join(dir, path.basename(backupFile)); // basename to prevent path traversal
|
|
116
|
+
|
|
117
|
+
if (!fs.existsSync(src)) {
|
|
118
|
+
return { success: false, message: `Backup file not found: ${backupFile}` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (DB_ENGINE === 'sqlite') {
|
|
122
|
+
const sqliteDb = db.getDb();
|
|
123
|
+
// better-sqlite3 exposes .name as the file path of the opened database
|
|
124
|
+
const dbPath = sqliteDb.name;
|
|
125
|
+
// Close, copy, re-open would require server restart — use SQLite's deserialization
|
|
126
|
+
// For simplicity, copy the backup over the current DB file
|
|
127
|
+
sqliteDb.close();
|
|
128
|
+
fs.copyFileSync(src, dbPath);
|
|
129
|
+
logger.info(`Restored backup: ${src} → ${dbPath}`);
|
|
130
|
+
return { success: true, message: `Database restored from ${backupFile}. Server restart may be required.` };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (DB_ENGINE === 'postgres') {
|
|
134
|
+
const args = [
|
|
135
|
+
'-h', process.env.DB_HOST || 'localhost',
|
|
136
|
+
'-p', process.env.DB_PORT || '5432',
|
|
137
|
+
'-U', process.env.DB_USERNAME || 'postgres',
|
|
138
|
+
'-d', process.env.DB_DATABASE || 'manifest',
|
|
139
|
+
'-f', src,
|
|
140
|
+
];
|
|
141
|
+
execFileSync('psql', args, {
|
|
142
|
+
env: { ...process.env, PGPASSWORD: process.env.DB_PASSWORD || 'postgres' },
|
|
143
|
+
timeout: 120000,
|
|
144
|
+
});
|
|
145
|
+
logger.info(`Restored backup: ${src}`);
|
|
146
|
+
return { success: true, message: `Database restored from ${backupFile}` };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (DB_ENGINE === 'mysql') {
|
|
150
|
+
const content = fs.readFileSync(src, 'utf-8');
|
|
151
|
+
const args = [
|
|
152
|
+
'-h', process.env.DB_HOST || 'localhost',
|
|
153
|
+
'-P', process.env.DB_PORT || '3306',
|
|
154
|
+
'-u', process.env.DB_USERNAME || 'root',
|
|
155
|
+
process.env.DB_DATABASE || 'manifest',
|
|
156
|
+
];
|
|
157
|
+
const env = { ...process.env };
|
|
158
|
+
if (process.env.DB_PASSWORD) env.MYSQL_PWD = process.env.DB_PASSWORD;
|
|
159
|
+
execFileSync('mysql', args, { env, input: content, timeout: 120000 });
|
|
160
|
+
logger.info(`Restored backup: ${src}`);
|
|
161
|
+
return { success: true, message: `Database restored from ${backupFile}` };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { success: false, message: `Unsupported database engine: ${DB_ENGINE}` };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* List available backups.
|
|
169
|
+
*
|
|
170
|
+
* @param {object|null} backupCfg Value of `core.backup` (may be null).
|
|
171
|
+
* @returns {Array<{ file: string, size: number, createdAt: string }>}
|
|
172
|
+
*/
|
|
173
|
+
function listBackups(backupCfg) {
|
|
174
|
+
const dir = getBackupDir(backupCfg);
|
|
175
|
+
if (!fs.existsSync(dir)) return [];
|
|
176
|
+
|
|
177
|
+
return fs.readdirSync(dir)
|
|
178
|
+
.filter((f) => f.startsWith('backup-'))
|
|
179
|
+
.map((f) => {
|
|
180
|
+
const stats = fs.statSync(path.join(dir, f));
|
|
181
|
+
return { file: f, size: stats.size, createdAt: stats.mtime.toISOString() };
|
|
182
|
+
})
|
|
183
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); // newest first
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
getBackupDir,
|
|
188
|
+
createBackup,
|
|
189
|
+
restoreBackup,
|
|
190
|
+
listBackups,
|
|
191
|
+
};
|
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) {
|