webspresso 0.0.98 → 0.0.100
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/.agents/ADMIN.md +11 -1
- package/.agents/AGENTS.md +2 -2
- package/.agents/AUTH.md +415 -0
- package/.agents/CLI.md +47 -0
- package/.agents/ERRORS.md +59 -0
- package/.agents/ORM.md +178 -4
- package/.agents/PLUGINS.md +113 -8
- package/.agents/QUEUE.md +92 -0
- package/.agents/ROUTING.md +160 -7
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/index.js +123 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +477 -3
- package/core/auth/middleware.js +118 -5
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +507 -0
- package/core/auth/user-agent.js +161 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +10 -1
- package/core/orm/model.js +55 -1
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +327 -16
- package/core/orm/scopes.js +3 -2
- package/core/orm/search.js +213 -0
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +544 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +100 -1
- package/core/queue/scheduler.js +415 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +794 -6
- package/index.js +48 -0
- package/package.json +7 -5
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
- package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -70
- package/plugins/audit-log/api-handlers.js +114 -0
- package/plugins/audit-log/index.js +24 -0
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/cors.js +13 -2
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/file-manager/admin-component.js +69 -53
- package/plugins/file-manager/index.js +25 -8
- package/plugins/index.js +8 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -0
- package/src/bundle/index.js +2 -1
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +310 -104
- package/src/helpers.js +16 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/routing/cascading.js +490 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +6 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy Check Command (Production Preflight Verification)
|
|
3
|
+
* Validates environment, secrets, container hardening, database migrations, and health checks
|
|
4
|
+
* @module bin/commands/deploy-check
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { resolveDbConfigIfExists, createDbInstance } = require('../utils/db');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Known weak secret patterns that should never be used in production
|
|
15
|
+
*/
|
|
16
|
+
const WEAK_SECRET_PATTERNS = [
|
|
17
|
+
/change-me/i,
|
|
18
|
+
/your-secret/i,
|
|
19
|
+
/super-secret/i,
|
|
20
|
+
/secret-key/i,
|
|
21
|
+
/webspresso-secret/i,
|
|
22
|
+
/123456/,
|
|
23
|
+
/password/i,
|
|
24
|
+
/^secret$/i,
|
|
25
|
+
/^default$/i,
|
|
26
|
+
/^test$/i,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load .env file into key-value map without modifying process.env
|
|
31
|
+
* @param {string} envPath
|
|
32
|
+
* @returns {Record<string, string>}
|
|
33
|
+
*/
|
|
34
|
+
function parseEnvFile(envPath) {
|
|
35
|
+
if (!fs.existsSync(envPath)) return {};
|
|
36
|
+
const content = fs.readFileSync(envPath, 'utf8');
|
|
37
|
+
const result = {};
|
|
38
|
+
for (const line of content.split('\n')) {
|
|
39
|
+
const trimmed = line.trim();
|
|
40
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
41
|
+
const eqIdx = trimmed.indexOf('=');
|
|
42
|
+
if (eqIdx > 0) {
|
|
43
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
44
|
+
let val = trimmed.slice(eqIdx + 1).trim();
|
|
45
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
46
|
+
val = val.slice(1, -1);
|
|
47
|
+
}
|
|
48
|
+
result[key] = val;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Run production preflight checks
|
|
56
|
+
* @param {Object} options
|
|
57
|
+
* @param {string} [options.cwd=process.cwd()]
|
|
58
|
+
* @param {string} [options.env='production']
|
|
59
|
+
* @returns {Promise<{ passed: boolean, errors: Array<{ check: string, message: string }>, warnings: Array<{ check: string, message: string }>, successes: Array<{ check: string, message: string }> }>}
|
|
60
|
+
*/
|
|
61
|
+
async function runDeployCheck(options = {}) {
|
|
62
|
+
const cwd = options.cwd || process.cwd();
|
|
63
|
+
const targetEnv = options.env || 'production';
|
|
64
|
+
|
|
65
|
+
const successes = [];
|
|
66
|
+
const warnings = [];
|
|
67
|
+
const errors = [];
|
|
68
|
+
|
|
69
|
+
const addPass = (check, message) => successes.push({ check, message });
|
|
70
|
+
const addWarn = (check, message) => warnings.push({ check, message });
|
|
71
|
+
const addError = (check, message) => errors.push({ check, message });
|
|
72
|
+
|
|
73
|
+
// Load environment variables from process.env and local .env files
|
|
74
|
+
const localEnv = {
|
|
75
|
+
...parseEnvFile(path.join(cwd, '.env')),
|
|
76
|
+
...parseEnvFile(path.join(cwd, `.env.${targetEnv}`)),
|
|
77
|
+
...process.env,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// ----------------------------------------------------
|
|
81
|
+
// 1. Project & Server Basics
|
|
82
|
+
// ----------------------------------------------------
|
|
83
|
+
const pkgPath = path.join(cwd, 'package.json');
|
|
84
|
+
let pkg = {};
|
|
85
|
+
if (!fs.existsSync(pkgPath)) {
|
|
86
|
+
addError('package.json', 'package.json not found in current directory');
|
|
87
|
+
} else {
|
|
88
|
+
try {
|
|
89
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
90
|
+
addPass('package.json', `Readable (${pkg.name || 'unnamed'})`);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
addError('package.json', `Invalid JSON: ${e.message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const serverPath = path.join(cwd, 'server.js');
|
|
97
|
+
let serverCode = '';
|
|
98
|
+
if (fs.existsSync(serverPath)) {
|
|
99
|
+
serverCode = fs.readFileSync(serverPath, 'utf8');
|
|
100
|
+
addPass('server.js', 'server.js entrypoint found');
|
|
101
|
+
} else {
|
|
102
|
+
addError('server.js', 'server.js not found (required for production runtime)');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ----------------------------------------------------
|
|
106
|
+
// 2. Production Secrets & Cryptography
|
|
107
|
+
// ----------------------------------------------------
|
|
108
|
+
const sessionSecret = localEnv.SESSION_SECRET || localEnv.AUTH_SESSION_SECRET || localEnv.APP_KEY || '';
|
|
109
|
+
if (!sessionSecret) {
|
|
110
|
+
addError('SESSION_SECRET', 'SESSION_SECRET or APP_KEY environment variable is not set');
|
|
111
|
+
} else if (sessionSecret.length < 32) {
|
|
112
|
+
addError('SESSION_SECRET', `SESSION_SECRET is only ${sessionSecret.length} chars (must be >= 32 chars in production)`);
|
|
113
|
+
} else if (WEAK_SECRET_PATTERNS.some((p) => p.test(sessionSecret))) {
|
|
114
|
+
addError('SESSION_SECRET', 'SESSION_SECRET appears to use a default or easily guessable placeholder');
|
|
115
|
+
} else {
|
|
116
|
+
addPass('SESSION_SECRET', 'Sufficiently strong cryptographic secret configured (>= 32 chars)');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const jwtSecret = localEnv.JWT_SECRET || '';
|
|
120
|
+
if (jwtSecret) {
|
|
121
|
+
if (jwtSecret.length < 32) {
|
|
122
|
+
addWarn('JWT_SECRET', `JWT_SECRET is only ${jwtSecret.length} chars (recommend >= 32 chars)`);
|
|
123
|
+
} else if (WEAK_SECRET_PATTERNS.some((p) => p.test(jwtSecret))) {
|
|
124
|
+
addError('JWT_SECRET', 'JWT_SECRET uses an insecure default placeholder');
|
|
125
|
+
} else {
|
|
126
|
+
addPass('JWT_SECRET', 'Strong JWT secret configured (>= 32 chars)');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ----------------------------------------------------
|
|
131
|
+
// 3. Git & File Security (.gitignore / .dockerignore)
|
|
132
|
+
// ----------------------------------------------------
|
|
133
|
+
const gitignorePath = path.join(cwd, '.gitignore');
|
|
134
|
+
if (fs.existsSync(gitignorePath)) {
|
|
135
|
+
const gitignore = fs.readFileSync(gitignorePath, 'utf8');
|
|
136
|
+
if (!gitignore.includes('.env')) {
|
|
137
|
+
addError('.gitignore', '.env is not ignored in .gitignore (risk of leaking secrets to git)');
|
|
138
|
+
} else {
|
|
139
|
+
addPass('.gitignore', '.env files properly excluded from version control');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (gitignore.includes('.sqlite') || gitignore.includes('*.db')) {
|
|
143
|
+
addPass('.gitignore', 'Local database files (*.sqlite, *.db) excluded from git');
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
addWarn('.gitignore', '.gitignore file not found');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const dockerignorePath = path.join(cwd, '.dockerignore');
|
|
150
|
+
if (fs.existsSync(dockerignorePath)) {
|
|
151
|
+
const dignore = fs.readFileSync(dockerignorePath, 'utf8');
|
|
152
|
+
if (!dignore.includes('node_modules') || !dignore.includes('.env')) {
|
|
153
|
+
addWarn('.dockerignore', '.dockerignore should explicitly exclude node_modules and .env');
|
|
154
|
+
} else {
|
|
155
|
+
addPass('.dockerignore', 'node_modules and .env excluded from Docker build context');
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
addWarn('.dockerignore', '.dockerignore not found (recommend running: npx webspresso add:docker)');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ----------------------------------------------------
|
|
162
|
+
// 4. Docker & Container Hardening
|
|
163
|
+
// ----------------------------------------------------
|
|
164
|
+
const dockerfilePath = path.join(cwd, 'Dockerfile');
|
|
165
|
+
if (fs.existsSync(dockerfilePath)) {
|
|
166
|
+
const dockerfile = fs.readFileSync(dockerfilePath, 'utf8');
|
|
167
|
+
|
|
168
|
+
if (/USER\s+(?!root\b)\w+/i.test(dockerfile)) {
|
|
169
|
+
addPass('Dockerfile User', 'Container executes as non-root unprivileged user');
|
|
170
|
+
} else {
|
|
171
|
+
addWarn('Dockerfile User', 'Container does not appear to switch to a non-root USER (security risk)');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (/HEALTHCHECK/i.test(dockerfile)) {
|
|
175
|
+
addPass('Dockerfile Healthcheck', 'Container HEALTHCHECK instruction configured');
|
|
176
|
+
} else {
|
|
177
|
+
addWarn('Dockerfile Healthcheck', 'No HEALTHCHECK instruction found in Dockerfile');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (/FROM.*AS\s+\w+/i.test(dockerfile)) {
|
|
181
|
+
addPass('Dockerfile Multi-Stage', 'Multi-stage Docker build configured');
|
|
182
|
+
} else {
|
|
183
|
+
addWarn('Dockerfile Multi-Stage', 'Single-stage Dockerfile detected (multi-stage recommended for smaller image size)');
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
addWarn('Dockerfile', 'Dockerfile not found (run `npx webspresso add:docker` to generate)');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ----------------------------------------------------
|
|
190
|
+
// 5. Database & Migrations
|
|
191
|
+
// ----------------------------------------------------
|
|
192
|
+
const migrationsDir = path.join(cwd, 'migrations');
|
|
193
|
+
if (fs.existsSync(migrationsDir)) {
|
|
194
|
+
const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith('.js') || f.endsWith('.ts'));
|
|
195
|
+
addPass('Migrations', `Found ${files.length} migration file(s) in migrations/`);
|
|
196
|
+
|
|
197
|
+
// Try verifying migration status if db config exists
|
|
198
|
+
const dbConfig = resolveDbConfigIfExists(cwd, targetEnv);
|
|
199
|
+
if (dbConfig) {
|
|
200
|
+
try {
|
|
201
|
+
const db = createDbInstance(dbConfig);
|
|
202
|
+
const status = await db.knex.migrate.status();
|
|
203
|
+
if (status > 0) {
|
|
204
|
+
addWarn('Pending Migrations', `There are ${status} pending database migration(s) to run (run: npx webspresso db:migrate)`);
|
|
205
|
+
} else if (status === 0) {
|
|
206
|
+
addPass('Pending Migrations', 'All database migrations are applied up to date');
|
|
207
|
+
}
|
|
208
|
+
await db.knex.destroy();
|
|
209
|
+
} catch (err) {
|
|
210
|
+
addWarn('Database Connectivity', `Could not check migration status: ${err.message}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
addPass('Migrations', 'No migrations directory (skipping migration status check)');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ----------------------------------------------------
|
|
218
|
+
// 6. Server Optimizations (Compression & Graceful Shutdown)
|
|
219
|
+
// ----------------------------------------------------
|
|
220
|
+
if (serverCode) {
|
|
221
|
+
if (serverCode.includes('compression: true') || serverCode.includes('compression:')) {
|
|
222
|
+
addPass('HTTP Compression', 'Response compression (Brotli/Gzip) enabled');
|
|
223
|
+
} else {
|
|
224
|
+
addWarn('HTTP Compression', 'Response compression not explicitly enabled in createApp({ server: { compression: true } })');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (serverCode.includes('shutdown:') || serverCode.includes('ShutdownManager')) {
|
|
228
|
+
addPass('Graceful Shutdown', 'Graceful shutdown handling configured');
|
|
229
|
+
} else {
|
|
230
|
+
addPass('Graceful Shutdown', 'Default SIGTERM graceful shutdown active');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (serverCode.includes('healthCheckPlugin') || serverCode.includes('/health') || serverCode.includes('/healthz')) {
|
|
234
|
+
addPass('Health Check Endpoint', 'Health check route configured');
|
|
235
|
+
} else {
|
|
236
|
+
addWarn('Health Check Endpoint', 'No /health or /healthz route found (recommend healthCheckPlugin or health endpoint for orchestrators)');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ----------------------------------------------------
|
|
241
|
+
// 7. Client Assets & Build Scripts
|
|
242
|
+
// ----------------------------------------------------
|
|
243
|
+
if (pkg.scripts && (pkg.scripts['build:css'] || pkg.scripts['build:js'] || pkg.scripts['build'])) {
|
|
244
|
+
const publicDir = path.join(cwd, 'public');
|
|
245
|
+
if (fs.existsSync(publicDir)) {
|
|
246
|
+
addPass('Asset Build', 'Build scripts and public/ directory found');
|
|
247
|
+
} else {
|
|
248
|
+
addWarn('Asset Build', 'Build script defined in package.json but public/ directory is missing');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const passed = errors.length === 0;
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
passed,
|
|
256
|
+
successes,
|
|
257
|
+
warnings,
|
|
258
|
+
errors,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Register command with Commander
|
|
264
|
+
* @param {import('commander').Command} program
|
|
265
|
+
*/
|
|
266
|
+
function registerCommand(program) {
|
|
267
|
+
program
|
|
268
|
+
.command('deploy:check')
|
|
269
|
+
.alias('check:deploy')
|
|
270
|
+
.description('Run production preflight check (secrets, container hardening, database, and optimizations)')
|
|
271
|
+
.option('-e, --env <environment>', 'Target environment to evaluate (default: production)', 'production')
|
|
272
|
+
.option('--strict', 'Fail with exit code 1 if any warnings are detected', false)
|
|
273
|
+
.option('--json', 'Output preflight results as JSON', false)
|
|
274
|
+
.action(async (...actionArgs) => {
|
|
275
|
+
const cmd = actionArgs[actionArgs.length - 1];
|
|
276
|
+
const options = (cmd && typeof cmd.opts === 'function')
|
|
277
|
+
? cmd.opts()
|
|
278
|
+
: (actionArgs.find((a) => a && typeof a === 'object') || {});
|
|
279
|
+
|
|
280
|
+
const result = await runDeployCheck({
|
|
281
|
+
cwd: process.cwd(),
|
|
282
|
+
env: options.env || 'production',
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
if (options.json) {
|
|
286
|
+
console.log(JSON.stringify(result, null, 2));
|
|
287
|
+
if (!result.passed || (options.strict && result.warnings.length > 0)) {
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
console.log('\n🚀 Webspresso Production Deployment Preflight');
|
|
294
|
+
console.log('=============================================\n');
|
|
295
|
+
|
|
296
|
+
if (result.successes.length > 0) {
|
|
297
|
+
console.log('Passed Checks:');
|
|
298
|
+
for (const item of result.successes) {
|
|
299
|
+
console.log(` ✅ [${item.check}] ${item.message}`);
|
|
300
|
+
}
|
|
301
|
+
console.log('');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (result.warnings.length > 0) {
|
|
305
|
+
console.log('Warnings:');
|
|
306
|
+
for (const item of result.warnings) {
|
|
307
|
+
console.log(` ⚠️ [${item.check}] ${item.message}`);
|
|
308
|
+
}
|
|
309
|
+
console.log('');
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (result.errors.length > 0) {
|
|
313
|
+
console.log('Errors (Must be resolved before production deployment):');
|
|
314
|
+
for (const item of result.errors) {
|
|
315
|
+
console.log(` ❌ [${item.check}] ${item.message}`);
|
|
316
|
+
}
|
|
317
|
+
console.log('');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
console.log('---------------------------------------------');
|
|
321
|
+
console.log(`Summary: ${result.successes.length} passed, ${result.warnings.length} warning(s), ${result.errors.length} error(s).\n`);
|
|
322
|
+
|
|
323
|
+
const hasFailed = !result.passed || (options.strict && result.warnings.length > 0);
|
|
324
|
+
|
|
325
|
+
if (hasFailed) {
|
|
326
|
+
console.error('❌ Deployment preflight checks FAILED.');
|
|
327
|
+
process.exit(1);
|
|
328
|
+
} else {
|
|
329
|
+
console.log('✅ Deployment preflight checks PASSED! Your application is ready for production.\n');
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
module.exports = {
|
|
335
|
+
registerCommand,
|
|
336
|
+
runDeployCheck,
|
|
337
|
+
parseEnvFile,
|
|
338
|
+
WEAK_SECRET_PATTERNS,
|
|
339
|
+
};
|
package/bin/webspresso.js
CHANGED
|
@@ -47,6 +47,8 @@ const { registerCommand: registerMakeModel } = require('./commands/make-model');
|
|
|
47
47
|
const { registerCommand: registerMakeJob } = require('./commands/make-job');
|
|
48
48
|
const { registerCommand: registerOpenapi } = require('./commands/openapi');
|
|
49
49
|
const { registerCommand: registerTypegen } = require('./commands/typegen');
|
|
50
|
+
const { registerCommand: registerAddDocker } = require('./commands/add-docker');
|
|
51
|
+
const { registerCommand: registerDeployCheck } = require('./commands/deploy-check');
|
|
50
52
|
|
|
51
53
|
registerNew(program);
|
|
52
54
|
registerPage(program);
|
|
@@ -56,6 +58,7 @@ registerStart(program);
|
|
|
56
58
|
registerBuild(program);
|
|
57
59
|
registerAddTailwind(program);
|
|
58
60
|
registerAddEsbuild(program);
|
|
61
|
+
registerAddDocker(program);
|
|
59
62
|
registerDbMigrate(program);
|
|
60
63
|
registerDbRollback(program);
|
|
61
64
|
registerDbStatus(program);
|
|
@@ -71,6 +74,7 @@ registerEmailPrune(program);
|
|
|
71
74
|
registerQueuePrune(program);
|
|
72
75
|
registerUploadPrune(program);
|
|
73
76
|
registerDoctor(program);
|
|
77
|
+
registerDeployCheck(program);
|
|
74
78
|
registerUpgrade(program);
|
|
75
79
|
registerOrmMap(program);
|
|
76
80
|
registerService(program);
|
package/core/auth/index.js
CHANGED
|
@@ -15,6 +15,56 @@ const {
|
|
|
15
15
|
dropAuthTokensTable,
|
|
16
16
|
createKnexAuthTokensAdapter,
|
|
17
17
|
} = require('./tokens');
|
|
18
|
+
const {
|
|
19
|
+
OAuthClient,
|
|
20
|
+
OAuthError,
|
|
21
|
+
createOAuthClient,
|
|
22
|
+
generatePKCE,
|
|
23
|
+
createSignedState,
|
|
24
|
+
verifySignedState,
|
|
25
|
+
DEFAULT_PROVIDERS,
|
|
26
|
+
} = require('./oauth');
|
|
27
|
+
const totp = require('./totp');
|
|
28
|
+
const {
|
|
29
|
+
base32Encode,
|
|
30
|
+
base32Decode,
|
|
31
|
+
generateTotpSecret,
|
|
32
|
+
generateHotp,
|
|
33
|
+
verifyHotp,
|
|
34
|
+
generateTotp,
|
|
35
|
+
verifyTotp,
|
|
36
|
+
generateTotpUri,
|
|
37
|
+
generateTotpQrSvg,
|
|
38
|
+
generateTotpQrDataUri,
|
|
39
|
+
generateRecoveryCodes,
|
|
40
|
+
hashRecoveryCode,
|
|
41
|
+
verifyRecoveryCode,
|
|
42
|
+
createTotpSetup,
|
|
43
|
+
} = totp;
|
|
44
|
+
const {
|
|
45
|
+
createQrMatrix,
|
|
46
|
+
generateQrSvg,
|
|
47
|
+
generateQrDataUri,
|
|
48
|
+
} = require('./qrcode');
|
|
49
|
+
const {
|
|
50
|
+
buildMagicLinkUrl,
|
|
51
|
+
formatMagicLinkHtml,
|
|
52
|
+
createMagicLinkVerifyMiddleware,
|
|
53
|
+
createMagicLinkRequestHandler,
|
|
54
|
+
} = require('./magic-link');
|
|
55
|
+
const {
|
|
56
|
+
createUserSessionsTable,
|
|
57
|
+
dropUserSessionsTable,
|
|
58
|
+
createKnexSessionTracker,
|
|
59
|
+
createMemorySessionTracker,
|
|
60
|
+
getClientIp,
|
|
61
|
+
parseUserAgent,
|
|
62
|
+
} = require('./sessions');
|
|
63
|
+
const {
|
|
64
|
+
SessionManager,
|
|
65
|
+
createListSessionsHandler,
|
|
66
|
+
createRevokeSessionHandler,
|
|
67
|
+
} = require('./session-manager');
|
|
18
68
|
|
|
19
69
|
/**
|
|
20
70
|
* Create authentication instance
|
|
@@ -42,6 +92,8 @@ function createAuth(config) {
|
|
|
42
92
|
* @param {Object} [options.session] - Session config
|
|
43
93
|
* @param {boolean} [options.rememberMe=true] - Enable remember me
|
|
44
94
|
* @param {boolean} [options.authTokens=false] - Enable auth_tokens adapter (password reset / email verify)
|
|
95
|
+
* @param {boolean} [options.magicLink=false] - Enable magic link / passwordless auth tokens adapter
|
|
96
|
+
* @param {boolean} [options.sessionTracking=false] - Enable active session tracking & revocation
|
|
45
97
|
* @param {Object|boolean} [options.jwt=false] - Enable JWT support or JWT config
|
|
46
98
|
* @param {string} [options.verifiedField='email_verified_at'] - Verified timestamp column
|
|
47
99
|
* @returns {AuthManager}
|
|
@@ -55,6 +107,8 @@ function quickAuth(options) {
|
|
|
55
107
|
session = {},
|
|
56
108
|
rememberMe = true,
|
|
57
109
|
authTokens: enableAuthTokens = false,
|
|
110
|
+
magicLink = false,
|
|
111
|
+
sessionTracking = false,
|
|
58
112
|
jwt = false,
|
|
59
113
|
verifiedField = 'email_verified_at',
|
|
60
114
|
} = options;
|
|
@@ -120,10 +174,15 @@ function quickAuth(options) {
|
|
|
120
174
|
};
|
|
121
175
|
}
|
|
122
176
|
|
|
123
|
-
if (enableAuthTokens) {
|
|
177
|
+
if (enableAuthTokens || magicLink) {
|
|
124
178
|
config.authTokens = createKnexAuthTokensAdapter(db);
|
|
125
179
|
}
|
|
126
180
|
|
|
181
|
+
if (sessionTracking) {
|
|
182
|
+
config.sessionTracking = true;
|
|
183
|
+
config.sessionTracker = createKnexSessionTracker(db);
|
|
184
|
+
}
|
|
185
|
+
|
|
127
186
|
return createAuth(config);
|
|
128
187
|
}
|
|
129
188
|
|
|
@@ -195,6 +254,69 @@ module.exports = {
|
|
|
195
254
|
createKnexAuthTokensAdapter,
|
|
196
255
|
TOKEN_TYPES,
|
|
197
256
|
|
|
257
|
+
// OAuth 2.0 & Social Login
|
|
258
|
+
OAuthClient,
|
|
259
|
+
OAuthError,
|
|
260
|
+
createOAuthClient,
|
|
261
|
+
generatePKCE,
|
|
262
|
+
createSignedState,
|
|
263
|
+
verifySignedState,
|
|
264
|
+
DEFAULT_PROVIDERS,
|
|
265
|
+
|
|
266
|
+
// 2FA / TOTP (RFC 6238, RFC 4226, RFC 4648)
|
|
267
|
+
totp,
|
|
268
|
+
base32Encode,
|
|
269
|
+
base32Decode,
|
|
270
|
+
generateTotpSecret,
|
|
271
|
+
generateHotp,
|
|
272
|
+
verifyHotp,
|
|
273
|
+
generateTotp,
|
|
274
|
+
verifyTotp,
|
|
275
|
+
generateTotpUri,
|
|
276
|
+
generateTotpQrSvg,
|
|
277
|
+
generateTotpQrDataUri,
|
|
278
|
+
generateRecoveryCodes,
|
|
279
|
+
hashRecoveryCode,
|
|
280
|
+
verifyRecoveryCode,
|
|
281
|
+
createTotpSetup,
|
|
282
|
+
createQrMatrix,
|
|
283
|
+
generateQrSvg,
|
|
284
|
+
generateQrDataUri,
|
|
285
|
+
|
|
286
|
+
// Magic Link / Passwordless Authentication
|
|
287
|
+
buildMagicLinkUrl,
|
|
288
|
+
formatMagicLinkHtml,
|
|
289
|
+
createMagicLinkVerifyMiddleware,
|
|
290
|
+
createMagicLinkRequestHandler,
|
|
291
|
+
magicLink: {
|
|
292
|
+
buildMagicLinkUrl,
|
|
293
|
+
formatMagicLinkHtml,
|
|
294
|
+
createMagicLinkVerifyMiddleware,
|
|
295
|
+
createMagicLinkRequestHandler,
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
// Active Devices & Session Management
|
|
299
|
+
SessionManager,
|
|
300
|
+
createUserSessionsTable,
|
|
301
|
+
dropUserSessionsTable,
|
|
302
|
+
createKnexSessionTracker,
|
|
303
|
+
createMemorySessionTracker,
|
|
304
|
+
getClientIp,
|
|
305
|
+
parseUserAgent,
|
|
306
|
+
createListSessionsHandler,
|
|
307
|
+
createRevokeSessionHandler,
|
|
308
|
+
sessions: {
|
|
309
|
+
SessionManager,
|
|
310
|
+
createUserSessionsTable,
|
|
311
|
+
dropUserSessionsTable,
|
|
312
|
+
createKnexSessionTracker,
|
|
313
|
+
createMemorySessionTracker,
|
|
314
|
+
getClientIp,
|
|
315
|
+
parseUserAgent,
|
|
316
|
+
createListSessionsHandler,
|
|
317
|
+
createRevokeSessionHandler,
|
|
318
|
+
},
|
|
319
|
+
|
|
198
320
|
// Config
|
|
199
321
|
DEFAULT_CONFIG,
|
|
200
322
|
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Magic Link & Passwordless Authentication Module
|
|
3
|
+
* Cryptographically signed one-time tokens, session login, and verification helpers
|
|
4
|
+
* @module core/auth/magic-link
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Construct full magic link verification URL
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {string} [options.baseUrl] - Base site URL (e.g. 'https://example.com')
|
|
14
|
+
* @param {string} [options.path='/auth/magic-link/verify'] - Relative path
|
|
15
|
+
* @param {string} options.token - Raw unhashed token
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function buildMagicLinkUrl(options = {}) {
|
|
19
|
+
let baseUrl = String(options.baseUrl || process.env.BASE_URL || 'http://localhost:3000');
|
|
20
|
+
while (baseUrl.endsWith('/')) {
|
|
21
|
+
baseUrl = baseUrl.slice(0, -1);
|
|
22
|
+
}
|
|
23
|
+
const path = options.path || '/auth/magic-link/verify';
|
|
24
|
+
const token = options.token;
|
|
25
|
+
|
|
26
|
+
if (!token) {
|
|
27
|
+
throw new Error('Token is required to build magic link URL');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cleanPath = path.startsWith('/') ? path : `/${path}`;
|
|
31
|
+
const sep = cleanPath.includes('?') ? '&' : '?';
|
|
32
|
+
return `${baseUrl}${cleanPath}${sep}token=${encodeURIComponent(token)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Default HTML template for magic link emails
|
|
37
|
+
*
|
|
38
|
+
* @param {Object} data
|
|
39
|
+
* @param {string} [data.name='there']
|
|
40
|
+
* @param {string} data.magicLinkUrl
|
|
41
|
+
* @param {number} [data.expiresMinutes=15]
|
|
42
|
+
* @returns {string} Clean HTML email body
|
|
43
|
+
*/
|
|
44
|
+
function formatMagicLinkHtml(data) {
|
|
45
|
+
const name = data.name || 'there';
|
|
46
|
+
const url = data.magicLinkUrl;
|
|
47
|
+
const minutes = data.expiresMinutes || 15;
|
|
48
|
+
|
|
49
|
+
return `<!DOCTYPE html>
|
|
50
|
+
<html>
|
|
51
|
+
<head>
|
|
52
|
+
<meta charset="utf-8">
|
|
53
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
54
|
+
<title>Sign in to your account</title>
|
|
55
|
+
</head>
|
|
56
|
+
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background-color: #f4f4f5; margin: 0; padding: 24px; color: #18181b;">
|
|
57
|
+
<div style="max-width: 520px; margin: 0 auto; background: #ffffff; border-radius: 16px; padding: 32px; border: 1px solid #e4e4e7;">
|
|
58
|
+
<h2 style="margin-top: 0; font-size: 22px; font-weight: 700; color: #18181b;">Sign in to your account</h2>
|
|
59
|
+
<p style="font-size: 15px; line-height: 1.6; color: #52525b;">Hello ${name},</p>
|
|
60
|
+
<p style="font-size: 15px; line-height: 1.6; color: #52525b;">Click the button below to sign in to your account without a password. This link is valid for <strong>${minutes} minutes</strong>.</p>
|
|
61
|
+
<div style="margin: 28px 0; text-align: center;">
|
|
62
|
+
<a href="${url}" style="display: inline-block; background-color: #4f46e5; color: #ffffff; text-decoration: none; padding: 12px 28px; font-weight: 600; font-size: 15px; border-radius: 12px;">Sign In →</a>
|
|
63
|
+
</div>
|
|
64
|
+
<p style="font-size: 13px; line-height: 1.5; color: #a1a1aa; border-top: 1px solid #f4f4f5; padding-top: 16px;">If you did not request this link, you can safely ignore this email.</p>
|
|
65
|
+
</div>
|
|
66
|
+
</body>
|
|
67
|
+
</html>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Express / Webspresso route middleware for magic link verification and session login
|
|
72
|
+
*
|
|
73
|
+
* @param {import('./manager').AuthManager} authManager
|
|
74
|
+
* @param {Object} [options]
|
|
75
|
+
* @param {string} [options.tokenParam='token'] - Query / body parameter name
|
|
76
|
+
* @param {string} [options.redirectTo='/'] - Redirect URL on successful HTML login
|
|
77
|
+
* @param {string} [options.failureRedirect='/login?error=invalid_magic_link'] - Redirect URL on failure
|
|
78
|
+
* @param {boolean} [options.markEmailVerified=true] - Auto-verify email on login
|
|
79
|
+
* @param {boolean} [options.jwt=false] - Generate JWT tokens in response
|
|
80
|
+
* @returns {Function} Express middleware
|
|
81
|
+
*/
|
|
82
|
+
function createMagicLinkVerifyMiddleware(authManager, options = {}) {
|
|
83
|
+
const {
|
|
84
|
+
tokenParam = 'token',
|
|
85
|
+
redirectTo = '/',
|
|
86
|
+
failureRedirect = '/login?error=invalid_magic_link',
|
|
87
|
+
markEmailVerified = true,
|
|
88
|
+
jwt = false,
|
|
89
|
+
} = options;
|
|
90
|
+
|
|
91
|
+
return async function magicLinkVerifyMiddleware(req, res, next) {
|
|
92
|
+
const rawToken = req.query?.[tokenParam] || req.body?.[tokenParam];
|
|
93
|
+
const isHtml = req.accepts && req.accepts('html') && req.method === 'GET';
|
|
94
|
+
|
|
95
|
+
if (!rawToken) {
|
|
96
|
+
if (isHtml) {
|
|
97
|
+
return res.redirect(failureRedirect);
|
|
98
|
+
}
|
|
99
|
+
return res.status(400).json({ error: 'Missing magic link token' });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const { user } = await authManager.verifyMagicLink(rawToken, { markEmailVerified });
|
|
104
|
+
|
|
105
|
+
if (req.auth && typeof req.auth.login === 'function') {
|
|
106
|
+
await req.auth.login(user);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let jwtTokens = null;
|
|
110
|
+
if (jwt) {
|
|
111
|
+
jwtTokens = {
|
|
112
|
+
accessToken: authManager.generateUserToken(user),
|
|
113
|
+
refreshToken: authManager.generateRefreshToken(user),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (isHtml) {
|
|
118
|
+
return res.redirect(redirectTo);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return res.status(200).json({
|
|
122
|
+
ok: true,
|
|
123
|
+
user,
|
|
124
|
+
...(jwtTokens || {}),
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (isHtml) {
|
|
128
|
+
return res.redirect(failureRedirect);
|
|
129
|
+
}
|
|
130
|
+
return res.status(400).json({
|
|
131
|
+
error: err.message || 'Invalid or expired magic link',
|
|
132
|
+
code: err.code || 'INVALID_MAGIC_LINK',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Express / Webspresso route handler for requesting a magic link
|
|
140
|
+
*
|
|
141
|
+
* @param {import('./manager').AuthManager} authManager
|
|
142
|
+
* @param {Object} [options]
|
|
143
|
+
* @returns {Function} Express route handler
|
|
144
|
+
*/
|
|
145
|
+
function createMagicLinkRequestHandler(authManager, options = {}) {
|
|
146
|
+
return async function magicLinkRequestHandler(req, res) {
|
|
147
|
+
const identifier = req.body?.email || req.body?.identifier || req.query?.email;
|
|
148
|
+
if (!identifier) {
|
|
149
|
+
return res.status(400).json({ error: 'Email or identifier is required' });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const result = await authManager.requestMagicLink(identifier, options);
|
|
154
|
+
return res.status(200).json({
|
|
155
|
+
ok: true,
|
|
156
|
+
message: 'If the account exists, a magic link has been sent.',
|
|
157
|
+
expiresAt: result.expiresAt,
|
|
158
|
+
});
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return res.status(500).json({ error: err.message || 'Failed to send magic link' });
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = {
|
|
166
|
+
buildMagicLinkUrl,
|
|
167
|
+
formatMagicLinkHtml,
|
|
168
|
+
createMagicLinkVerifyMiddleware,
|
|
169
|
+
createMagicLinkRequestHandler,
|
|
170
|
+
};
|