create-lumina-project 1.0.1 ā 1.1.1
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/bin/cli.js +106 -28
- package/package.json +2 -1
- package/template/.env.example +1 -0
- package/template/package.json +60 -55
- package/template/public/js/status.js +53 -0
- package/template/server.ts +50 -8
- package/template/src/config/database.ts +11 -13
- package/template/src/config/env.ts +54 -0
- package/template/src/controllers/AuthController.ts +30 -0
- package/template/src/database/migrations/20260222151300-create_refresh_tokens.js +63 -0
- package/template/src/exceptions/Handler.ts +10 -3
- package/template/src/middlewares/Authentication.ts +2 -5
- package/template/src/middlewares/Csrf.ts +52 -0
- package/template/src/middlewares/Maintenance.ts +2 -4
- package/template/src/middlewares/RequestLogger.ts +31 -0
- package/template/src/models/RefreshToken.ts +69 -0
- package/template/src/models/index.ts +4 -6
- package/template/src/routes/api.ts +5 -0
- package/template/src/routes/web.ts +34 -5
- package/template/src/services/AuthService.ts +101 -9
- package/template/src/services/StorageService.ts +36 -9
- package/template/src/tests/apiResponse.test.ts +66 -0
- package/template/src/tests/env.test.ts +99 -0
- package/template/src/tests/hash.test.ts +30 -0
- package/template/src/utils/Logger.ts +3 -5
- package/template/views/404.html +233 -0
- package/template/views/maintenance.html +445 -23
- package/template/views/status.html +545 -0
- package/template/views/welcome.html +472 -39
- package/template/vitest.config.ts +10 -0
package/bin/cli.js
CHANGED
|
@@ -3,70 +3,148 @@
|
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import prompts from 'prompts';
|
|
6
|
-
import { bold, green, cyan, red } from 'kleur/colors';
|
|
6
|
+
import { bold, green, cyan, red, yellow } from 'kleur/colors';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
|
+
import { exec } from 'child_process';
|
|
9
|
+
import util from 'util';
|
|
10
|
+
import ora from 'ora';
|
|
11
|
+
|
|
12
|
+
const execAsync = util.promisify(exec);
|
|
8
13
|
|
|
9
14
|
// Get current directory of the script
|
|
10
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
16
|
const __dirname = path.dirname(__filename);
|
|
12
17
|
|
|
13
18
|
async function init() {
|
|
14
|
-
console.log(bold(cyan('
|
|
19
|
+
console.log(`\n${bold(cyan('š Welcome to the Lumina Installer!'))}\n`);
|
|
15
20
|
|
|
16
21
|
// 1. Ask for Project Name
|
|
17
22
|
let targetDir = process.argv[2];
|
|
18
23
|
|
|
19
24
|
if (!targetDir) {
|
|
20
|
-
const response = await prompts(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
const response = await prompts(
|
|
26
|
+
{
|
|
27
|
+
type: 'text',
|
|
28
|
+
name: 'projectName',
|
|
29
|
+
message: 'What is your project name?',
|
|
30
|
+
initial: 'my-lumina-app',
|
|
31
|
+
validate: (name) => {
|
|
32
|
+
if (!name.trim()) return 'Project name cannot be empty.';
|
|
33
|
+
if (!/^[a-z0-9-_]+$/.test(name)) {
|
|
34
|
+
return 'Project name can only contain lowercase letters, numbers, dashes, and underscores.';
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
onCancel: () => {
|
|
41
|
+
console.log(red('ā Operation cancelled'));
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
targetDir = response.projectName.trim();
|
|
32
48
|
}
|
|
33
49
|
|
|
34
|
-
const projectPath = path.
|
|
50
|
+
const projectPath = path.resolve(process.cwd(), targetDir);
|
|
35
51
|
|
|
36
52
|
// 2. Check if folder exists
|
|
37
53
|
if (fs.existsSync(projectPath)) {
|
|
38
54
|
console.log(red(`\nā Error: Directory "${targetDir}" already exists.`));
|
|
39
55
|
process.exit(1);
|
|
40
56
|
}
|
|
57
|
+
|
|
58
|
+
// 3. Prompt for extra setups (git and npm install)
|
|
59
|
+
const setupPreferences = await prompts(
|
|
60
|
+
[
|
|
61
|
+
{
|
|
62
|
+
type: 'confirm',
|
|
63
|
+
name: 'initGit',
|
|
64
|
+
message: 'Initialize a new git repository?',
|
|
65
|
+
initial: true
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
type: 'confirm',
|
|
69
|
+
name: 'installDeps',
|
|
70
|
+
message: 'Install dependencies automatically?',
|
|
71
|
+
initial: true
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
{
|
|
75
|
+
onCancel: () => {
|
|
76
|
+
console.log(red('ā Operation cancelled'));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
);
|
|
41
81
|
|
|
42
|
-
console.log(
|
|
82
|
+
console.log(''); // Blank space for readability
|
|
43
83
|
|
|
44
|
-
|
|
84
|
+
const copySpinner = ora(`š Creating project in ${green(projectPath)}...`).start();
|
|
85
|
+
|
|
86
|
+
// 4. Copy Template
|
|
45
87
|
const templateDir = path.resolve(__dirname, '../template');
|
|
46
88
|
|
|
47
89
|
try {
|
|
48
90
|
await fs.copy(templateDir, projectPath);
|
|
49
91
|
|
|
50
|
-
//
|
|
92
|
+
// 5. Rename _gitignore to .gitignore
|
|
51
93
|
const gitignorePath = path.join(projectPath, '_gitignore');
|
|
52
94
|
if (fs.existsSync(gitignorePath)) {
|
|
53
95
|
fs.renameSync(gitignorePath, path.join(projectPath, '.gitignore'));
|
|
54
96
|
}
|
|
55
97
|
|
|
56
|
-
//
|
|
98
|
+
// 6. Update package.json name
|
|
57
99
|
const pkgPath = path.join(projectPath, 'package.json');
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
100
|
+
if (fs.existsSync(pkgPath)) {
|
|
101
|
+
const pkg = await fs.readJson(pkgPath);
|
|
102
|
+
pkg.name = path.basename(projectPath);
|
|
103
|
+
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
104
|
+
}
|
|
61
105
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
106
|
+
copySpinner.succeed(green('Project template copied successfully!'));
|
|
107
|
+
|
|
108
|
+
// 7. Initialize Git
|
|
109
|
+
if (setupPreferences.initGit) {
|
|
110
|
+
const gitSpinner = ora('Initializing git repository...').start();
|
|
111
|
+
try {
|
|
112
|
+
await execAsync('git init', { cwd: projectPath });
|
|
113
|
+
// Optional: add initial commit but usually people prefer to do it themselves or standard tooling just inits
|
|
114
|
+
gitSpinner.succeed(green('Git repository initialized.'));
|
|
115
|
+
} catch (error) {
|
|
116
|
+
gitSpinner.fail(yellow('Failed to initialize git repository (is git installed?)'));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 8. Install Dependencies
|
|
121
|
+
if (setupPreferences.installDeps) {
|
|
122
|
+
const depsSpinner = ora('Installing dependencies (this might take a few minutes)...').start();
|
|
123
|
+
try {
|
|
124
|
+
await execAsync('npm install', { cwd: projectPath });
|
|
125
|
+
depsSpinner.succeed(green('Dependencies installed successfully!'));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
depsSpinner.fail(red('Failed to install dependencies. You can run "npm install" manually later.'));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 9. Success message & Next steps
|
|
132
|
+
console.log(`\n${bold(green('ā
All done! Project setup complete.'))}`);
|
|
133
|
+
console.log('\nTo get started run:\n');
|
|
134
|
+
|
|
135
|
+
const cdCmd = projectPath === process.cwd() ? '' : `cd ${targetDir}`;
|
|
136
|
+
if (cdCmd) console.log(cyan(` ${cdCmd}`));
|
|
137
|
+
|
|
138
|
+
if (!setupPreferences.installDeps) {
|
|
139
|
+
console.log(cyan(' npm install'));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log(cyan(' npm run dev\n'));
|
|
67
143
|
|
|
68
144
|
} catch (error) {
|
|
69
|
-
|
|
145
|
+
copySpinner.fail(red('Error copying files:'));
|
|
146
|
+
console.error(error);
|
|
147
|
+
process.exit(1);
|
|
70
148
|
}
|
|
71
149
|
}
|
|
72
150
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-lumina-project",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"author": "Glenson Ansin",
|
|
5
5
|
"description": "Scaffold a modern Node.js API with Lumina architecture.",
|
|
6
6
|
"type": "module",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"dependencies": {
|
|
15
15
|
"fs-extra": "^11.3.3",
|
|
16
16
|
"kleur": "^4.1.5",
|
|
17
|
+
"ora": "^9.3.0",
|
|
17
18
|
"prompts": "^2.4.2"
|
|
18
19
|
},
|
|
19
20
|
"license": "MIT",
|
package/template/.env.example
CHANGED
package/template/package.json
CHANGED
|
@@ -1,55 +1,60 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "lumina",
|
|
3
|
-
"version": "1.0.0",
|
|
4
|
-
"description": "",
|
|
5
|
-
"main": "server.ts",
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"migrate
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"create:
|
|
20
|
-
"create:
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"dependencies": {
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"express
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"pg
|
|
40
|
-
"
|
|
41
|
-
"sequelize
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"@
|
|
49
|
-
"@types/
|
|
50
|
-
"@types/
|
|
51
|
-
"@types/
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "lumina",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "server.ts",
|
|
6
|
+
"private": true,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "nodemon --exec tsx server.ts",
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"start": "node dist/server.js",
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"migrate": "tsx scripts/migrate.ts up",
|
|
14
|
+
"migrate:undo": "tsx scripts/migrate.ts down",
|
|
15
|
+
"migrate:reset": "tsx scripts/migrate.ts reset",
|
|
16
|
+
"db:seed": "tsx scripts/seed.ts",
|
|
17
|
+
"down": "tsx scripts/maintenance.ts down",
|
|
18
|
+
"up": "tsx scripts/maintenance.ts up",
|
|
19
|
+
"create:model": "tsx scripts/create-model.ts",
|
|
20
|
+
"create:migration": "tsx scripts/create-migration.ts",
|
|
21
|
+
"create:controller": "tsx scripts/create-controller.ts",
|
|
22
|
+
"create:factory": "tsx scripts/create-factory.ts",
|
|
23
|
+
"key:generate": "tsx scripts/generate-keys.ts"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"bcrypt": "^6.0.0",
|
|
27
|
+
"bcryptjs": "^3.0.3",
|
|
28
|
+
"compression": "^1.8.1",
|
|
29
|
+
"cookie-parser": "^1.4.7",
|
|
30
|
+
"cors": "^2.8.6",
|
|
31
|
+
"dotenv": "^17.2.3",
|
|
32
|
+
"express": "^5.2.1",
|
|
33
|
+
"express-rate-limit": "^8.2.1",
|
|
34
|
+
"helmet": "^8.1.0",
|
|
35
|
+
"jsonwebtoken": "^9.0.3",
|
|
36
|
+
"multer": "^2.0.2",
|
|
37
|
+
"mysql2": "^3.16.2",
|
|
38
|
+
"nodemon": "^3.1.11",
|
|
39
|
+
"pg": "^8.18.0",
|
|
40
|
+
"pg-hstore": "^2.3.4",
|
|
41
|
+
"sequelize": "^6.37.7",
|
|
42
|
+
"sequelize-cli": "^6.6.5",
|
|
43
|
+
"umzug": "^3.8.2",
|
|
44
|
+
"winston": "^3.19.0",
|
|
45
|
+
"zod": "^4.3.6"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@faker-js/faker": "^10.3.0",
|
|
49
|
+
"@types/bcrypt": "^6.0.0",
|
|
50
|
+
"@types/compression": "^1.8.1",
|
|
51
|
+
"@types/cookie-parser": "^1.4.10",
|
|
52
|
+
"@types/cors": "^2.8.19",
|
|
53
|
+
"@types/express": "^5.0.6",
|
|
54
|
+
"@types/jsonwebtoken": "^9.0.10",
|
|
55
|
+
"@types/multer": "^2.0.0",
|
|
56
|
+
"tsx": "^4.21.0",
|
|
57
|
+
"typescript": "^5.9.3",
|
|
58
|
+
"vitest": "^4.0.18"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
async function fetchStatus() {
|
|
2
|
+
try {
|
|
3
|
+
const res = await fetch('/status/json');
|
|
4
|
+
const data = await res.json();
|
|
5
|
+
|
|
6
|
+
// Status
|
|
7
|
+
const heroEl = document.getElementById('statusHero');
|
|
8
|
+
const heroText = document.getElementById('statusHeroText');
|
|
9
|
+
const appStatus = document.getElementById('appStatus');
|
|
10
|
+
const isUp = data.status === 'UP';
|
|
11
|
+
|
|
12
|
+
heroEl.className = 'status-hero ' + (isUp ? 'up' : 'down');
|
|
13
|
+
heroText.textContent = isUp ? 'All Systems Operational' : 'System Degraded';
|
|
14
|
+
appStatus.textContent = data.status;
|
|
15
|
+
appStatus.className = 'info-value ' + (isUp ? 'status-up' : 'status-down');
|
|
16
|
+
|
|
17
|
+
// Environment
|
|
18
|
+
document.getElementById('appEnv').textContent = data.environment || 'unknown';
|
|
19
|
+
|
|
20
|
+
// Uptime
|
|
21
|
+
if (data.uptime !== undefined) {
|
|
22
|
+
document.getElementById('appUptime').textContent = formatUptime(data.uptime);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Memory
|
|
26
|
+
if (data.memoryMB !== undefined) {
|
|
27
|
+
document.getElementById('appMemory').textContent = data.memoryMB + ' MB';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Timestamp
|
|
31
|
+
document.getElementById('lastChecked').textContent = 'Checked ' + new Date().toLocaleTimeString();
|
|
32
|
+
} catch (err) {
|
|
33
|
+
document.getElementById('statusHero').className = 'status-hero down';
|
|
34
|
+
document.getElementById('statusHeroText').textContent = 'Unable to Reach Server';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function formatUptime(seconds) {
|
|
39
|
+
const d = Math.floor(seconds / 86400);
|
|
40
|
+
const h = Math.floor((seconds % 86400) / 3600);
|
|
41
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
42
|
+
const s = Math.floor(seconds % 60);
|
|
43
|
+
const parts = [];
|
|
44
|
+
if (d > 0) parts.push(d + 'd');
|
|
45
|
+
if (h > 0) parts.push(h + 'h');
|
|
46
|
+
if (m > 0) parts.push(m + 'm');
|
|
47
|
+
parts.push(s + 's');
|
|
48
|
+
return parts.join(' ');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Fetch immediately, then refresh every 30s
|
|
52
|
+
fetchStatus();
|
|
53
|
+
setInterval(fetchStatus, 30000);
|
package/template/server.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import express, { Application } from 'express';
|
|
2
2
|
import cors from 'cors';
|
|
3
3
|
import helmet from 'helmet';
|
|
4
|
+
import compression from 'compression';
|
|
5
|
+
import cookieParser from 'cookie-parser';
|
|
4
6
|
import path from 'path';
|
|
5
7
|
import db from './src/models/index.js';
|
|
6
8
|
import RouteService from './src/services/RouteService.js';
|
|
@@ -8,23 +10,28 @@ import ExceptionHandler from './src/exceptions/Handler.js';
|
|
|
8
10
|
import Logger from './src/utils/Logger.js';
|
|
9
11
|
import Limiter from './src/middlewares/Limiter.js';
|
|
10
12
|
import Maintenance from './src/middlewares/Maintenance.js';
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
dotenv.config();
|
|
13
|
+
import RequestLogger from './src/middlewares/RequestLogger.js';
|
|
14
|
+
import env from './src/config/env.js';
|
|
14
15
|
|
|
15
16
|
const app: Application = express();
|
|
16
|
-
const PORT =
|
|
17
|
+
const PORT = env.APP_PORT;
|
|
17
18
|
|
|
18
19
|
// ==========================
|
|
19
20
|
// Global Middleware
|
|
20
21
|
// ==========================
|
|
21
22
|
app.use(Maintenance.handle);
|
|
22
23
|
app.use(helmet({ crossOriginResourcePolicy: false }));
|
|
23
|
-
app.use(cors(
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
app.use(cors({
|
|
25
|
+
origin: env.CORS_ORIGIN,
|
|
26
|
+
credentials: true,
|
|
27
|
+
}));
|
|
28
|
+
app.use(compression());
|
|
29
|
+
app.use(cookieParser());
|
|
30
|
+
app.use(express.json({ limit: '10kb' }));
|
|
31
|
+
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
|
|
26
32
|
app.use(express.static(path.join(process.cwd(), 'public')));
|
|
27
33
|
app.use(Limiter.global);
|
|
34
|
+
app.use(RequestLogger.handle);
|
|
28
35
|
|
|
29
36
|
// ==========================
|
|
30
37
|
// Register Routes
|
|
@@ -45,13 +52,15 @@ app.use(ExceptionHandler.handle);
|
|
|
45
52
|
// ==========================
|
|
46
53
|
// Start Server
|
|
47
54
|
// ==========================
|
|
55
|
+
let server: ReturnType<typeof app.listen>;
|
|
56
|
+
|
|
48
57
|
const start = async () => {
|
|
49
58
|
try {
|
|
50
59
|
// Test Database Connection
|
|
51
60
|
await db.connect();
|
|
52
61
|
|
|
53
62
|
// Start Listening
|
|
54
|
-
app.listen(PORT, () => {
|
|
63
|
+
server = app.listen(PORT, () => {
|
|
55
64
|
Logger.info(`Server running on http://localhost:${PORT}`);
|
|
56
65
|
});
|
|
57
66
|
} catch (error) {
|
|
@@ -60,4 +69,37 @@ const start = async () => {
|
|
|
60
69
|
}
|
|
61
70
|
};
|
|
62
71
|
|
|
72
|
+
// ==========================
|
|
73
|
+
// Graceful Shutdown
|
|
74
|
+
// ==========================
|
|
75
|
+
const shutdown = async (signal: string) => {
|
|
76
|
+
Logger.info(`${signal} received. Shutting down gracefully...`);
|
|
77
|
+
|
|
78
|
+
if (server) {
|
|
79
|
+
server.close(async () => {
|
|
80
|
+
Logger.info('HTTP server closed.');
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
await db.sequelize.close();
|
|
84
|
+
Logger.info('Database connection closed.');
|
|
85
|
+
} catch (error) {
|
|
86
|
+
Logger.error('Error closing database connection:', error);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
process.exit(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Force shutdown after 10 seconds
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
Logger.error('Forced shutdown after timeout.');
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}, 10000);
|
|
97
|
+
} else {
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
103
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
104
|
+
|
|
63
105
|
start();
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { Options } from 'sequelize';
|
|
2
2
|
import Logger from '../utils/Logger.js';
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
dotenv.config();
|
|
3
|
+
import env from './env.js';
|
|
6
4
|
|
|
7
5
|
class DatabaseConfig {
|
|
8
6
|
public development: Options;
|
|
@@ -19,18 +17,18 @@ class DatabaseConfig {
|
|
|
19
17
|
* Helper to generate config based on the environment.
|
|
20
18
|
* This reduces repetition.
|
|
21
19
|
*/
|
|
22
|
-
private getEnvironmentConfig(
|
|
23
|
-
const isTest =
|
|
24
|
-
const isProd =
|
|
25
|
-
const useSSL =
|
|
20
|
+
private getEnvironmentConfig(envName: 'development' | 'test' | 'production' = 'development'): Options {
|
|
21
|
+
const isTest = envName === 'test';
|
|
22
|
+
const isProd = envName === 'production';
|
|
23
|
+
const useSSL = env.DB_SSL === 'true';
|
|
26
24
|
|
|
27
25
|
return {
|
|
28
|
-
username:
|
|
29
|
-
password:
|
|
30
|
-
database: isTest ?
|
|
31
|
-
host:
|
|
32
|
-
port:
|
|
33
|
-
dialect:
|
|
26
|
+
username: env.DB_USERNAME,
|
|
27
|
+
password: env.DB_PASSWORD,
|
|
28
|
+
database: isTest ? env.DB_DATABASE_TEST : env.DB_DATABASE,
|
|
29
|
+
host: env.DB_HOST,
|
|
30
|
+
port: env.DB_PORT,
|
|
31
|
+
dialect: env.DB_DIALECT as any,
|
|
34
32
|
dialectOptions: useSSL ? {
|
|
35
33
|
ssl: {
|
|
36
34
|
require: true,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import dotenv from 'dotenv';
|
|
3
|
+
|
|
4
|
+
dotenv.config();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Centralized Environment Validation
|
|
8
|
+
*
|
|
9
|
+
* Validates all required environment variables at startup
|
|
10
|
+
* and exports a typed `env` object for use throughout the app.
|
|
11
|
+
*/
|
|
12
|
+
const envSchema = z.object({
|
|
13
|
+
// Application
|
|
14
|
+
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
|
15
|
+
APP_PORT: z.coerce.number().default(3000),
|
|
16
|
+
|
|
17
|
+
// Database
|
|
18
|
+
DB_DIALECT: z.enum(['mysql', 'postgres', 'mariadb', 'sqlite']).default('mysql'),
|
|
19
|
+
DB_HOST: z.string().min(1, 'DB_HOST is required'),
|
|
20
|
+
DB_PORT: z.coerce.number().default(3306),
|
|
21
|
+
DB_DATABASE: z.string().min(1, 'DB_DATABASE is required'),
|
|
22
|
+
DB_DATABASE_TEST: z.string().optional(),
|
|
23
|
+
DB_USERNAME: z.string().min(1, 'DB_USERNAME is required'),
|
|
24
|
+
DB_PASSWORD: z.string().default(''),
|
|
25
|
+
DB_SSL: z.string().optional(),
|
|
26
|
+
|
|
27
|
+
// JWT
|
|
28
|
+
JWT_SECRET: z.string().min(16, 'JWT_SECRET must be at least 16 characters for security'),
|
|
29
|
+
JWT_EXPIRES_IN: z.string().default('15m'),
|
|
30
|
+
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
|
|
31
|
+
|
|
32
|
+
// CORS
|
|
33
|
+
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
|
34
|
+
|
|
35
|
+
// Logging
|
|
36
|
+
LOG_LEVEL: z.string().default('info'),
|
|
37
|
+
|
|
38
|
+
// Maintenance
|
|
39
|
+
MAINTENANCE_SECRET: z.string().optional(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const parsed = envSchema.safeParse(process.env);
|
|
43
|
+
|
|
44
|
+
if (!parsed.success) {
|
|
45
|
+
console.error('ā Invalid environment variables:');
|
|
46
|
+
for (const issue of parsed.error.issues) {
|
|
47
|
+
console.error(` ${issue.path.join('.')}: ${issue.message}`);
|
|
48
|
+
}
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const env = parsed.data;
|
|
53
|
+
|
|
54
|
+
export default env;
|
|
@@ -13,6 +13,36 @@ class AuthController {
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
public async refresh(req: Request, res: Response) {
|
|
17
|
+
try {
|
|
18
|
+
const { refreshToken } = req.body;
|
|
19
|
+
|
|
20
|
+
if (!refreshToken) {
|
|
21
|
+
return ApiResponse.error(res, 'Refresh token is required', 400);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const result = await AuthService.refresh(refreshToken);
|
|
25
|
+
return ApiResponse.success(res, result, 'Token refreshed successfully');
|
|
26
|
+
} catch (error: any) {
|
|
27
|
+
return ApiResponse.error(res, error.message, 401);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public async logout(req: Request, res: Response) {
|
|
32
|
+
try {
|
|
33
|
+
const { refreshToken } = req.body;
|
|
34
|
+
|
|
35
|
+
if (!refreshToken) {
|
|
36
|
+
return ApiResponse.error(res, 'Refresh token is required', 400);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await AuthService.logout(refreshToken);
|
|
40
|
+
return ApiResponse.success(res, null, 'Logged out successfully');
|
|
41
|
+
} catch (error: any) {
|
|
42
|
+
return ApiResponse.error(res, error.message, 500);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
16
46
|
public async me(req: Request, res: Response) {
|
|
17
47
|
return ApiResponse.success(res, req.user);
|
|
18
48
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { DataTypes } from 'sequelize';
|
|
2
|
+
|
|
3
|
+
class CreateRefreshTokensTable {
|
|
4
|
+
/**
|
|
5
|
+
* Run the migrations.
|
|
6
|
+
*/
|
|
7
|
+
async up(queryInterface, Sequelize) {
|
|
8
|
+
await queryInterface.createTable('refresh_tokens', {
|
|
9
|
+
id: {
|
|
10
|
+
allowNull: false,
|
|
11
|
+
autoIncrement: true,
|
|
12
|
+
primaryKey: true,
|
|
13
|
+
type: DataTypes.BIGINT,
|
|
14
|
+
},
|
|
15
|
+
user_id: {
|
|
16
|
+
type: DataTypes.BIGINT,
|
|
17
|
+
allowNull: false,
|
|
18
|
+
references: {
|
|
19
|
+
model: 'users',
|
|
20
|
+
key: 'id',
|
|
21
|
+
},
|
|
22
|
+
onUpdate: 'CASCADE',
|
|
23
|
+
onDelete: 'CASCADE',
|
|
24
|
+
},
|
|
25
|
+
token: {
|
|
26
|
+
type: DataTypes.STRING(512),
|
|
27
|
+
allowNull: false,
|
|
28
|
+
unique: true,
|
|
29
|
+
},
|
|
30
|
+
expires_at: {
|
|
31
|
+
type: DataTypes.DATE,
|
|
32
|
+
allowNull: false,
|
|
33
|
+
},
|
|
34
|
+
revoked: {
|
|
35
|
+
type: DataTypes.BOOLEAN,
|
|
36
|
+
allowNull: false,
|
|
37
|
+
defaultValue: false,
|
|
38
|
+
},
|
|
39
|
+
created_at: {
|
|
40
|
+
allowNull: false,
|
|
41
|
+
type: DataTypes.DATE,
|
|
42
|
+
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
|
43
|
+
},
|
|
44
|
+
updated_at: {
|
|
45
|
+
allowNull: false,
|
|
46
|
+
type: DataTypes.DATE,
|
|
47
|
+
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await queryInterface.addIndex('refresh_tokens', ['token']);
|
|
52
|
+
await queryInterface.addIndex('refresh_tokens', ['user_id']);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reverse the migrations.
|
|
57
|
+
*/
|
|
58
|
+
async down(queryInterface) {
|
|
59
|
+
await queryInterface.dropTable('refresh_tokens');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default new CreateRefreshTokensTable();
|