webspresso 0.0.97 → 0.0.99
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 +163 -5
- package/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/AUTH.md +169 -0
- package/.agents/CLI.md +195 -1
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +221 -9
- package/.agents/QUEUE.md +127 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- package/core/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +20 -7
- package/core/orm/model.js +53 -7
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +205 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +1188 -10
- package/index.js +88 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- 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/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- 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 -59
- package/plugins/audit-log/api-handlers.js +148 -0
- package/plugins/audit-log/index.js +153 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +10 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +15 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +4 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +347 -162
- package/src/helpers.js +967 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +635 -63
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso CLI - typegen / types Command
|
|
5
|
+
* Generate TypeScript type definitions and IDE auto-complete for models, services, and routes
|
|
6
|
+
* @module bin/commands/typegen
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { generateTypes } = require('../../src/dev/typegen');
|
|
12
|
+
|
|
13
|
+
function registerCommand(program) {
|
|
14
|
+
program
|
|
15
|
+
.command('typegen')
|
|
16
|
+
.alias('types')
|
|
17
|
+
.alias('generate:types')
|
|
18
|
+
.description('Generate TypeScript type definitions and IDE auto-complete for models, services, and routes')
|
|
19
|
+
.option('-o, --out <file>', 'Output declaration file path (default: .webspresso/types.d.ts)')
|
|
20
|
+
.option('-r, --root <dir>', 'Root directory of the project', process.cwd())
|
|
21
|
+
.option('--models <dir>', 'Models directory relative to root')
|
|
22
|
+
.option('--services <dir>', 'Services directory relative to root')
|
|
23
|
+
.option('--pages <dir>', 'Pages directory relative to root', 'pages')
|
|
24
|
+
.option('--no-env-file', 'Skip generating webspresso-env.d.ts at project root')
|
|
25
|
+
.option('-w, --watch', 'Watch models, services, and pages directories for changes')
|
|
26
|
+
.option('-s, --silent', 'Suppress console output', false)
|
|
27
|
+
.action((options) => {
|
|
28
|
+
const rootDir = path.resolve(options.root || process.cwd());
|
|
29
|
+
|
|
30
|
+
const runOnce = () => {
|
|
31
|
+
const startTime = Date.now();
|
|
32
|
+
const result = generateTypes({
|
|
33
|
+
rootDir,
|
|
34
|
+
modelsDir: options.models,
|
|
35
|
+
servicesDir: options.services,
|
|
36
|
+
pagesDir: options.pages,
|
|
37
|
+
outFile: options.out,
|
|
38
|
+
createEnvFile: options.envFile !== false,
|
|
39
|
+
silent: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const elapsed = Date.now() - startTime;
|
|
43
|
+
if (!options.silent) {
|
|
44
|
+
const relOut = path.relative(rootDir, result.outFile);
|
|
45
|
+
console.log(`\n⚡ [Webspresso TypeGen] Completed in ${elapsed}ms`);
|
|
46
|
+
console.log(` ✓ Models: ${result.modelsCount}`);
|
|
47
|
+
console.log(` ✓ Services: ${result.servicesCount}`);
|
|
48
|
+
console.log(` ✓ Routes: ${result.routesCount}`);
|
|
49
|
+
console.log(` → Output: ${relOut}`);
|
|
50
|
+
if (result.envFile) {
|
|
51
|
+
console.log(` → Env Ref: ${path.relative(rootDir, result.envFile)}`);
|
|
52
|
+
}
|
|
53
|
+
console.log('');
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
runOnce();
|
|
59
|
+
|
|
60
|
+
// Watch mode
|
|
61
|
+
if (options.watch) {
|
|
62
|
+
if (!options.silent) {
|
|
63
|
+
console.log('👀 Watching models, services, and pages for changes... (Press Ctrl+C to stop)\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const watchDirs = [
|
|
67
|
+
options.models || 'models',
|
|
68
|
+
options.models || 'src/models',
|
|
69
|
+
options.services || 'services',
|
|
70
|
+
options.services || 'src/services',
|
|
71
|
+
options.pages || 'pages',
|
|
72
|
+
options.pages || 'src/pages',
|
|
73
|
+
]
|
|
74
|
+
.map((d) => path.resolve(rootDir, d))
|
|
75
|
+
.filter((d) => fs.existsSync(d));
|
|
76
|
+
|
|
77
|
+
const uniqueDirs = Array.from(new Set(watchDirs));
|
|
78
|
+
let debounceTimer = null;
|
|
79
|
+
|
|
80
|
+
for (const dir of uniqueDirs) {
|
|
81
|
+
try {
|
|
82
|
+
fs.watch(dir, { recursive: true }, (eventType, filename) => {
|
|
83
|
+
if (
|
|
84
|
+
filename &&
|
|
85
|
+
(filename.endsWith('.js') || filename.endsWith('.ts') || filename.endsWith('.njk'))
|
|
86
|
+
) {
|
|
87
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
88
|
+
debounceTimer = setTimeout(() => {
|
|
89
|
+
if (!options.silent) {
|
|
90
|
+
console.log(`🔄 Detected change in ${filename}, regenerating types...`);
|
|
91
|
+
}
|
|
92
|
+
runOnce();
|
|
93
|
+
}, 150);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
} catch {
|
|
97
|
+
// Watch unsupported on platform or directory
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
registerCommand,
|
|
106
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prune unreferenced / orphaned uploaded files (CLI)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { loadDbConfig, createDbInstance } = require('../utils/db');
|
|
9
|
+
const { pruneOrphanedUploads } = require('../../plugins/upload/orphan-pruner');
|
|
10
|
+
|
|
11
|
+
function registerCommand(program) {
|
|
12
|
+
program
|
|
13
|
+
.command('upload:prune')
|
|
14
|
+
.description('Scan and delete orphaned files in the uploads directory not referenced by any database model')
|
|
15
|
+
.option('--dir <path>', 'Custom uploads directory path (default: public/uploads)')
|
|
16
|
+
.option('--url-base <path>', 'Public URL base path (default: /uploads)', '/uploads')
|
|
17
|
+
.option('--dry-run', 'Scan and list orphaned files without deleting them', false)
|
|
18
|
+
.option('-e, --env <environment>', 'Environment (development, production)', 'development')
|
|
19
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
20
|
+
.action(async (options) => {
|
|
21
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
22
|
+
console.log(`\n📦 Using config: ${configPath}`);
|
|
23
|
+
console.log(` Environment: ${options.env}\n`);
|
|
24
|
+
|
|
25
|
+
const knex = await createDbInstance(config, options.env);
|
|
26
|
+
const db = { knex };
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const destDir = options.dir
|
|
30
|
+
? path.resolve(process.cwd(), options.dir)
|
|
31
|
+
: path.resolve(process.cwd(), 'public', 'uploads');
|
|
32
|
+
|
|
33
|
+
console.log(`🔍 Scanning directory: ${destDir}`);
|
|
34
|
+
console.log(` Public URL base: ${options.urlBase}`);
|
|
35
|
+
if (options.dryRun) {
|
|
36
|
+
console.log(` Mode: DRY RUN (no files will be deleted)\n`);
|
|
37
|
+
} else {
|
|
38
|
+
console.log(` Mode: PRUNE & DELETE\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = await pruneOrphanedUploads({
|
|
42
|
+
db,
|
|
43
|
+
destDir,
|
|
44
|
+
publicBasePath: options.urlBase,
|
|
45
|
+
dryRun: options.dryRun,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
console.log(`📊 Scan Summary:`);
|
|
49
|
+
console.log(` Total files on disk: ${result.totalFiles} (${result.totalBytesFormatted})`);
|
|
50
|
+
console.log(` Referenced by DB: ${result.referencedCount}`);
|
|
51
|
+
console.log(` Orphaned files: ${result.orphanedCount} (${result.orphanedBytesFormatted})\n`);
|
|
52
|
+
|
|
53
|
+
if (result.orphanedCount === 0) {
|
|
54
|
+
console.log(`✅ No orphaned files found. Storage is clean!\n`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (options.dryRun) {
|
|
59
|
+
console.log(`ℹ️ [DRY RUN] The following ${result.orphanedCount} file(s) would be deleted:`);
|
|
60
|
+
for (const file of result.orphanedFiles.slice(0, 20)) {
|
|
61
|
+
console.log(` - ${file.relativePath} (${file.publicUrl})`);
|
|
62
|
+
}
|
|
63
|
+
if (result.orphanedFiles.length > 20) {
|
|
64
|
+
console.log(` ... and ${result.orphanedFiles.length - 20} more file(s)`);
|
|
65
|
+
}
|
|
66
|
+
console.log(`\n💡 To permanently delete these files, run without --dry-run.\n`);
|
|
67
|
+
} else {
|
|
68
|
+
console.log(`🗑️ Successfully deleted ${result.deletedCount} orphaned file(s) (${result.deletedBytesFormatted} reclaimed).\n`);
|
|
69
|
+
}
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.error('❌ upload:prune failed:', err.message);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
} finally {
|
|
74
|
+
await knex.destroy();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { registerCommand };
|
package/bin/webspresso.js
CHANGED
|
@@ -18,7 +18,9 @@ const { registerCommand: registerPage } = require('./commands/page');
|
|
|
18
18
|
const { registerCommand: registerApi } = require('./commands/api');
|
|
19
19
|
const { registerCommand: registerDev } = require('./commands/dev');
|
|
20
20
|
const { registerCommand: registerStart } = require('./commands/start');
|
|
21
|
+
const { registerCommand: registerBuild } = require('./commands/build');
|
|
21
22
|
const { registerCommand: registerAddTailwind } = require('./commands/add-tailwind');
|
|
23
|
+
const { registerCommand: registerAddEsbuild } = require('./commands/add-esbuild');
|
|
22
24
|
const { registerCommand: registerDbMigrate } = require('./commands/db-migrate');
|
|
23
25
|
const { registerCommand: registerDbRollback } = require('./commands/db-rollback');
|
|
24
26
|
const { registerCommand: registerDbStatus } = require('./commands/db-status');
|
|
@@ -29,6 +31,10 @@ const { registerCommand: registerAdminSetup } = require('./commands/admin-setup'
|
|
|
29
31
|
const { registerCommand: registerAdminPassword } = require('./commands/admin-password');
|
|
30
32
|
const { registerCommand: registerFaviconGenerate } = require('./commands/favicon-generate');
|
|
31
33
|
const { registerCommand: registerAuditPrune } = require('./commands/audit-prune');
|
|
34
|
+
const { registerCommand: registerAnalyticsPrune } = require('./commands/analytics-prune');
|
|
35
|
+
const { registerCommand: registerEmailPrune } = require('./commands/email-prune');
|
|
36
|
+
const { registerCommand: registerQueuePrune } = require('./commands/queue-prune');
|
|
37
|
+
const { registerCommand: registerUploadPrune } = require('./commands/upload-prune');
|
|
32
38
|
const { registerCommand: registerDoctor } = require('./commands/doctor');
|
|
33
39
|
const { registerCommand: registerUpgrade } = require('./commands/upgrade');
|
|
34
40
|
const { registerCommand: registerOrmMap } = require('./commands/orm-map');
|
|
@@ -36,13 +42,20 @@ const { registerCommand: registerService } = require('./commands/service');
|
|
|
36
42
|
const { registerCommand: registerMcp } = require('./commands/mcp');
|
|
37
43
|
const { registerCommand: registerPolarMigrate } = require('./commands/polar-migrate');
|
|
38
44
|
const { registerCommand: registerAgentsInit } = require('./commands/agents-init');
|
|
45
|
+
const { registerCommand: registerRoutes } = require('./commands/routes');
|
|
46
|
+
const { registerCommand: registerMakeModel } = require('./commands/make-model');
|
|
47
|
+
const { registerCommand: registerMakeJob } = require('./commands/make-job');
|
|
48
|
+
const { registerCommand: registerOpenapi } = require('./commands/openapi');
|
|
49
|
+
const { registerCommand: registerTypegen } = require('./commands/typegen');
|
|
39
50
|
|
|
40
51
|
registerNew(program);
|
|
41
52
|
registerPage(program);
|
|
42
53
|
registerApi(program);
|
|
43
54
|
registerDev(program);
|
|
44
55
|
registerStart(program);
|
|
56
|
+
registerBuild(program);
|
|
45
57
|
registerAddTailwind(program);
|
|
58
|
+
registerAddEsbuild(program);
|
|
46
59
|
registerDbMigrate(program);
|
|
47
60
|
registerDbRollback(program);
|
|
48
61
|
registerDbStatus(program);
|
|
@@ -53,6 +66,10 @@ registerAdminSetup(program);
|
|
|
53
66
|
registerAdminPassword(program);
|
|
54
67
|
registerFaviconGenerate(program);
|
|
55
68
|
registerAuditPrune(program);
|
|
69
|
+
registerAnalyticsPrune(program);
|
|
70
|
+
registerEmailPrune(program);
|
|
71
|
+
registerQueuePrune(program);
|
|
72
|
+
registerUploadPrune(program);
|
|
56
73
|
registerDoctor(program);
|
|
57
74
|
registerUpgrade(program);
|
|
58
75
|
registerOrmMap(program);
|
|
@@ -60,6 +77,11 @@ registerService(program);
|
|
|
60
77
|
registerMcp(program);
|
|
61
78
|
registerPolarMigrate(program);
|
|
62
79
|
registerAgentsInit(program);
|
|
80
|
+
registerRoutes(program);
|
|
81
|
+
registerMakeModel(program);
|
|
82
|
+
registerMakeJob(program);
|
|
83
|
+
registerOpenapi(program);
|
|
84
|
+
registerTypegen(program);
|
|
63
85
|
|
|
64
86
|
// Parse arguments
|
|
65
87
|
program.parse();
|
package/core/applySchema.js
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
* Parses and validates request input against compiled schemas
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const EMPTY_INPUT = Object.freeze({
|
|
7
|
+
body: undefined,
|
|
8
|
+
params: undefined,
|
|
9
|
+
query: undefined,
|
|
10
|
+
});
|
|
11
|
+
|
|
6
12
|
/**
|
|
7
13
|
* Apply compiled schema to request
|
|
8
14
|
* Parses body, params, and query against their respective schemas
|
|
@@ -14,18 +20,19 @@
|
|
|
14
20
|
* @throws {ZodError} If validation fails
|
|
15
21
|
*/
|
|
16
22
|
function applySchema(req, compiledSchema) {
|
|
23
|
+
// No schema means no validation
|
|
24
|
+
if (!compiledSchema) {
|
|
25
|
+
req.input = EMPTY_INPUT;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
// Initialize req.input
|
|
18
30
|
req.input = {
|
|
19
31
|
body: undefined,
|
|
20
32
|
params: undefined,
|
|
21
|
-
query: undefined
|
|
33
|
+
query: undefined,
|
|
22
34
|
};
|
|
23
35
|
|
|
24
|
-
// No schema means no validation
|
|
25
|
-
if (!compiledSchema) {
|
|
26
|
-
return;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
36
|
// Parse body if schema exists
|
|
30
37
|
if (compiledSchema.body) {
|
|
31
38
|
req.input.body = compiledSchema.body.parse(req.body);
|
package/core/auth/hash.js
CHANGED
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
* @module core/auth/hash
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
let bcrypt;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
let bcrypt = null;
|
|
8
|
+
let bcryptChecked = false;
|
|
9
|
+
function getBcrypt() {
|
|
10
|
+
if (!bcryptChecked) {
|
|
11
|
+
bcryptChecked = true;
|
|
12
|
+
try {
|
|
13
|
+
bcrypt = require('bcrypt');
|
|
14
|
+
} catch {
|
|
15
|
+
bcrypt = null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return bcrypt;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
/**
|
|
@@ -25,7 +31,8 @@ const DEFAULT_ROUNDS = process.env.NODE_ENV === 'test' ? 1 : 12;
|
|
|
25
31
|
* @returns {Promise<string>} Hashed password
|
|
26
32
|
*/
|
|
27
33
|
async function hash(password, rounds = DEFAULT_ROUNDS) {
|
|
28
|
-
|
|
34
|
+
const b = getBcrypt();
|
|
35
|
+
if (!b) {
|
|
29
36
|
throw new Error('bcrypt is required for password hashing. Install it with: npm install bcrypt');
|
|
30
37
|
}
|
|
31
38
|
|
|
@@ -33,7 +40,7 @@ async function hash(password, rounds = DEFAULT_ROUNDS) {
|
|
|
33
40
|
throw new Error('Password must be a non-empty string');
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
return
|
|
43
|
+
return b.hash(password, rounds);
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
/**
|
|
@@ -43,7 +50,8 @@ async function hash(password, rounds = DEFAULT_ROUNDS) {
|
|
|
43
50
|
* @returns {Promise<boolean>} True if password matches
|
|
44
51
|
*/
|
|
45
52
|
async function verify(password, hashedPassword) {
|
|
46
|
-
|
|
53
|
+
const b = getBcrypt();
|
|
54
|
+
if (!b) {
|
|
47
55
|
throw new Error('bcrypt is required for password verification. Install it with: npm install bcrypt');
|
|
48
56
|
}
|
|
49
57
|
|
|
@@ -52,7 +60,7 @@ async function verify(password, hashedPassword) {
|
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
try {
|
|
55
|
-
return await
|
|
63
|
+
return await b.compare(password, hashedPassword);
|
|
56
64
|
} catch {
|
|
57
65
|
return false;
|
|
58
66
|
}
|
|
@@ -65,7 +73,8 @@ async function verify(password, hashedPassword) {
|
|
|
65
73
|
* @returns {boolean} True if rehash is needed
|
|
66
74
|
*/
|
|
67
75
|
function needsRehash(hashedPassword, rounds = DEFAULT_ROUNDS) {
|
|
68
|
-
|
|
76
|
+
const b = getBcrypt();
|
|
77
|
+
if (!b) {
|
|
69
78
|
throw new Error('bcrypt is required. Install it with: npm install bcrypt');
|
|
70
79
|
}
|
|
71
80
|
|
|
@@ -74,7 +83,7 @@ function needsRehash(hashedPassword, rounds = DEFAULT_ROUNDS) {
|
|
|
74
83
|
}
|
|
75
84
|
|
|
76
85
|
try {
|
|
77
|
-
const hashRounds =
|
|
86
|
+
const hashRounds = b.getRounds(hashedPassword);
|
|
78
87
|
return hashRounds < rounds;
|
|
79
88
|
} catch {
|
|
80
89
|
return true;
|
package/core/auth/index.js
CHANGED
|
@@ -15,6 +15,37 @@ 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');
|
|
18
49
|
|
|
19
50
|
/**
|
|
20
51
|
* Create authentication instance
|
|
@@ -195,6 +226,35 @@ module.exports = {
|
|
|
195
226
|
createKnexAuthTokensAdapter,
|
|
196
227
|
TOKEN_TYPES,
|
|
197
228
|
|
|
229
|
+
// OAuth 2.0 & Social Login
|
|
230
|
+
OAuthClient,
|
|
231
|
+
OAuthError,
|
|
232
|
+
createOAuthClient,
|
|
233
|
+
generatePKCE,
|
|
234
|
+
createSignedState,
|
|
235
|
+
verifySignedState,
|
|
236
|
+
DEFAULT_PROVIDERS,
|
|
237
|
+
|
|
238
|
+
// 2FA / TOTP (RFC 6238, RFC 4226, RFC 4648)
|
|
239
|
+
totp,
|
|
240
|
+
base32Encode,
|
|
241
|
+
base32Decode,
|
|
242
|
+
generateTotpSecret,
|
|
243
|
+
generateHotp,
|
|
244
|
+
verifyHotp,
|
|
245
|
+
generateTotp,
|
|
246
|
+
verifyTotp,
|
|
247
|
+
generateTotpUri,
|
|
248
|
+
generateTotpQrSvg,
|
|
249
|
+
generateTotpQrDataUri,
|
|
250
|
+
generateRecoveryCodes,
|
|
251
|
+
hashRecoveryCode,
|
|
252
|
+
verifyRecoveryCode,
|
|
253
|
+
createTotpSetup,
|
|
254
|
+
createQrMatrix,
|
|
255
|
+
generateQrSvg,
|
|
256
|
+
generateQrDataUri,
|
|
257
|
+
|
|
198
258
|
// Config
|
|
199
259
|
DEFAULT_CONFIG,
|
|
200
260
|
};
|
package/core/auth/manager.js
CHANGED
|
@@ -60,6 +60,7 @@ const DEFAULT_CONFIG = {
|
|
|
60
60
|
logout: '/logout',
|
|
61
61
|
home: '/',
|
|
62
62
|
redirectAfterLogin: '/dashboard',
|
|
63
|
+
twoFactor: '/2fa',
|
|
63
64
|
},
|
|
64
65
|
};
|
|
65
66
|
|
|
@@ -107,6 +108,10 @@ class AuthManager {
|
|
|
107
108
|
welcome: config.notifications?.welcome || null,
|
|
108
109
|
};
|
|
109
110
|
|
|
111
|
+
// TOTP & 2FA initialization
|
|
112
|
+
this.totp = require('./totp');
|
|
113
|
+
this.twoFactor = config.twoFactor || { enabled: false };
|
|
114
|
+
|
|
110
115
|
if (config.jwt) {
|
|
111
116
|
const jwtSecret = typeof config.jwt === 'object' && config.jwt.secret
|
|
112
117
|
? config.jwt.secret
|
|
@@ -329,6 +334,9 @@ class AuthManager {
|
|
|
329
334
|
}
|
|
330
335
|
}
|
|
331
336
|
|
|
337
|
+
// Clear 2FA session state
|
|
338
|
+
this.clear2Fa();
|
|
339
|
+
|
|
332
340
|
// Clear user
|
|
333
341
|
req.user = null;
|
|
334
342
|
|
|
@@ -377,6 +385,119 @@ class AuthManager {
|
|
|
377
385
|
return req.user?.id || null;
|
|
378
386
|
},
|
|
379
387
|
|
|
388
|
+
/**
|
|
389
|
+
* TOTP helper utilities (RFC 6238 / RFC 4226)
|
|
390
|
+
*/
|
|
391
|
+
totp: self.totp,
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Check if two-factor authentication has been verified in current session/request
|
|
395
|
+
* @returns {boolean}
|
|
396
|
+
*/
|
|
397
|
+
is2FaVerified() {
|
|
398
|
+
if (req.session?.twoFactorVerified) return true;
|
|
399
|
+
if (req.jwtPayload?.twoFactorVerified) return true;
|
|
400
|
+
return false;
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Check if two-factor authentication is pending verification
|
|
405
|
+
* @returns {boolean}
|
|
406
|
+
*/
|
|
407
|
+
is2FaPending() {
|
|
408
|
+
return Boolean(req.session?.twoFactorPending);
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Mark two-factor authentication as verified
|
|
413
|
+
* @param {boolean} [verified=true]
|
|
414
|
+
*/
|
|
415
|
+
set2FaVerified(verified = true) {
|
|
416
|
+
if (req.session) {
|
|
417
|
+
req.session.twoFactorVerified = Boolean(verified);
|
|
418
|
+
if (verified) {
|
|
419
|
+
delete req.session.twoFactorPending;
|
|
420
|
+
delete req.session.twoFactorUserId;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Mark two-factor authentication as pending verification for a user
|
|
427
|
+
* @param {Object} [user] - User object or user ID
|
|
428
|
+
*/
|
|
429
|
+
set2FaPending(user) {
|
|
430
|
+
if (req.session) {
|
|
431
|
+
req.session.twoFactorPending = true;
|
|
432
|
+
req.session.twoFactorVerified = false;
|
|
433
|
+
if (user?.id) {
|
|
434
|
+
req.session.twoFactorUserId = user.id;
|
|
435
|
+
} else if (typeof user === 'string' || typeof user === 'number') {
|
|
436
|
+
req.session.twoFactorUserId = user;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Clear two-factor authentication status from session
|
|
443
|
+
*/
|
|
444
|
+
clear2Fa() {
|
|
445
|
+
if (req.session) {
|
|
446
|
+
delete req.session.twoFactorVerified;
|
|
447
|
+
delete req.session.twoFactorPending;
|
|
448
|
+
delete req.session.twoFactorUserId;
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Verify submitted TOTP token or recovery code
|
|
454
|
+
* Automatically marks 2FA as verified upon success
|
|
455
|
+
* @param {string} tokenOrCode - Submitted 6-digit TOTP code or recovery code
|
|
456
|
+
* @param {Object|string} secretOrUser - Secret string or user object with totp_secret / recovery_codes
|
|
457
|
+
* @param {Object} [options]
|
|
458
|
+
* @returns {{ success: boolean, type?: 'totp'|'recovery_code', delta?: number, remainingHashedCodes?: string[], matchedIndex?: number }}
|
|
459
|
+
*/
|
|
460
|
+
verify2Fa(tokenOrCode, secretOrUser, options = {}) {
|
|
461
|
+
if (!tokenOrCode) {
|
|
462
|
+
return { success: false };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
let secret = null;
|
|
466
|
+
let recoveryCodes = null;
|
|
467
|
+
|
|
468
|
+
if (typeof secretOrUser === 'string') {
|
|
469
|
+
secret = secretOrUser;
|
|
470
|
+
} else if (secretOrUser && typeof secretOrUser === 'object') {
|
|
471
|
+
secret = secretOrUser.totp_secret || secretOrUser.totpSecret || secretOrUser.two_factor_secret || secretOrUser.twoFactorSecret;
|
|
472
|
+
recoveryCodes = secretOrUser.recovery_codes || secretOrUser.recoveryCodes || secretOrUser.two_factor_recovery_codes;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Try TOTP code first
|
|
476
|
+
if (secret) {
|
|
477
|
+
const totpResult = self.totp.verifyTotp(tokenOrCode, secret, { ...options, detailed: true });
|
|
478
|
+
if (totpResult.valid) {
|
|
479
|
+
this.set2FaVerified(true);
|
|
480
|
+
return { success: true, type: 'totp', delta: totpResult.delta };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Try recovery codes if provided
|
|
485
|
+
if (recoveryCodes && Array.isArray(recoveryCodes) && recoveryCodes.length > 0) {
|
|
486
|
+
const recResult = self.totp.verifyRecoveryCode(tokenOrCode, recoveryCodes);
|
|
487
|
+
if (recResult.valid) {
|
|
488
|
+
this.set2FaVerified(true);
|
|
489
|
+
return {
|
|
490
|
+
success: true,
|
|
491
|
+
type: 'recovery_code',
|
|
492
|
+
matchedIndex: recResult.matchedIndex,
|
|
493
|
+
remainingHashedCodes: recResult.remainingHashedCodes,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return { success: false };
|
|
499
|
+
},
|
|
500
|
+
|
|
380
501
|
/**
|
|
381
502
|
* Check if user can perform action (policy check)
|
|
382
503
|
* @param {string} action - Action name
|
|
@@ -801,6 +922,19 @@ class AuthManager {
|
|
|
801
922
|
}
|
|
802
923
|
return this._authMiddleware.requireJwt(options);
|
|
803
924
|
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Get require2Fa guard middleware for Express routes
|
|
928
|
+
* @param {Object} [options]
|
|
929
|
+
* @returns {Function} Express middleware
|
|
930
|
+
*/
|
|
931
|
+
require2Fa(options = {}) {
|
|
932
|
+
if (!this._authMiddleware) {
|
|
933
|
+
const { createAuthMiddleware } = require('./middleware');
|
|
934
|
+
this._authMiddleware = createAuthMiddleware(this);
|
|
935
|
+
}
|
|
936
|
+
return this._authMiddleware.require2Fa(options);
|
|
937
|
+
}
|
|
804
938
|
}
|
|
805
939
|
|
|
806
940
|
module.exports = {
|