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.
Files changed (88) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +2 -2
  3. package/.agents/AUTH.md +415 -0
  4. package/.agents/CLI.md +47 -0
  5. package/.agents/ERRORS.md +59 -0
  6. package/.agents/ORM.md +178 -4
  7. package/.agents/PLUGINS.md +113 -8
  8. package/.agents/QUEUE.md +92 -0
  9. package/.agents/ROUTING.md +160 -7
  10. package/.agents/STREAMING.md +35 -0
  11. package/AGENTS.md +2 -0
  12. package/README.md +145 -93
  13. package/bin/commands/add-docker.js +467 -0
  14. package/bin/commands/deploy-check.js +339 -0
  15. package/bin/webspresso.js +4 -0
  16. package/core/auth/index.js +123 -1
  17. package/core/auth/magic-link.js +170 -0
  18. package/core/auth/manager.js +477 -3
  19. package/core/auth/middleware.js +118 -5
  20. package/core/auth/oauth.js +517 -0
  21. package/core/auth/qrcode.js +499 -0
  22. package/core/auth/session-manager.js +255 -0
  23. package/core/auth/sessions.js +351 -0
  24. package/core/auth/tokens.js +1 -0
  25. package/core/auth/totp.js +507 -0
  26. package/core/auth/user-agent.js +161 -0
  27. package/core/orm/eager-loader.js +144 -10
  28. package/core/orm/index.js +10 -1
  29. package/core/orm/model.js +55 -1
  30. package/core/orm/query-builder.js +90 -4
  31. package/core/orm/repository.js +327 -16
  32. package/core/orm/scopes.js +3 -2
  33. package/core/orm/search.js +213 -0
  34. package/core/orm/types.js +6 -1
  35. package/core/queue/cron.js +544 -0
  36. package/core/queue/index.js +8 -1
  37. package/core/queue/manager.js +100 -1
  38. package/core/queue/scheduler.js +415 -0
  39. package/core/sse/index.js +19 -0
  40. package/core/sse/stream.js +430 -0
  41. package/core/ssr/stream.js +62 -2
  42. package/index.d.ts +794 -6
  43. package/index.js +48 -0
  44. package/package.json +7 -5
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  46. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  47. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  48. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  49. package/plugins/admin-panel/field-renderers/index.js +2 -0
  50. package/plugins/admin-panel/modules/admin-users.js +1 -1
  51. package/plugins/api-key/crypto.js +101 -0
  52. package/plugins/api-key/index.js +288 -0
  53. package/plugins/api-key/middleware.js +461 -0
  54. package/plugins/api-key/migration-template.js +48 -0
  55. package/plugins/api-key/model.js +50 -0
  56. package/plugins/api-key/scopes.js +125 -0
  57. package/plugins/audit-log/admin-component.js +487 -70
  58. package/plugins/audit-log/api-handlers.js +114 -0
  59. package/plugins/audit-log/index.js +24 -0
  60. package/plugins/content/client/inline-edit.js +2 -46
  61. package/plugins/cors.js +13 -2
  62. package/plugins/email/auth-bridge.js +66 -0
  63. package/plugins/email/bundled-templates.js +15 -0
  64. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  65. package/plugins/file-manager/admin-component.js +69 -53
  66. package/plugins/file-manager/index.js +25 -8
  67. package/plugins/index.js +8 -0
  68. package/plugins/oauth/index.js +338 -0
  69. package/plugins/queue/index.js +4 -0
  70. package/plugins/webhooks/admin-component.js +608 -0
  71. package/plugins/webhooks/api-handlers.js +237 -0
  72. package/plugins/webhooks/crypto.js +144 -0
  73. package/plugins/webhooks/dispatcher.js +708 -0
  74. package/plugins/webhooks/index.js +263 -0
  75. package/plugins/webhooks/matcher.js +113 -0
  76. package/plugins/webhooks/migration-template.js +75 -0
  77. package/plugins/webhooks/models.js +98 -0
  78. package/plugins/webhooks/sender.js +176 -0
  79. package/src/api/api-loader.js +1 -0
  80. package/src/bundle/index.js +2 -1
  81. package/src/discovery/file-route-parser.js +44 -1
  82. package/src/discovery/scan-routes.js +2 -2
  83. package/src/file-router.js +310 -104
  84. package/src/helpers.js +16 -1
  85. package/src/njk-frontmatter.js +3 -0
  86. package/src/routing/cascading.js +490 -0
  87. package/src/routing/middleware-resolver.js +92 -0
  88. 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);
@@ -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
+ };