servcraft 0.1.5 → 0.1.7
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/README.md +31 -3
- package/ROADMAP.md +320 -0
- package/dist/cli/index.cjs +2201 -74
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +2197 -74
- package/dist/cli/index.js.map +1 -1
- package/package.json +2 -2
- package/src/cli/commands/add-module.ts +52 -8
- package/src/cli/commands/init.ts +441 -65
- package/src/cli/index.ts +4 -0
package/dist/cli/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli/index.ts
|
|
4
|
-
import { Command as
|
|
4
|
+
import { Command as Command6 } from "commander";
|
|
5
5
|
|
|
6
6
|
// src/cli/commands/init.ts
|
|
7
7
|
import { Command } from "commander";
|
|
@@ -73,7 +73,7 @@ function getModulesDir() {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
// src/cli/commands/init.ts
|
|
76
|
-
var initCommand = new Command("init").alias("new").description("Initialize a new Servcraft project").argument("[name]", "Project name").option("-y, --yes", "Skip prompts and use defaults").option("--ts, --typescript", "Use TypeScript (default)").option("--js, --javascript", "Use JavaScript").option("--db <database>", "Database type (postgresql, mysql, sqlite, mongodb, none)").action(
|
|
76
|
+
var initCommand = new Command("init").alias("new").description("Initialize a new Servcraft project").argument("[name]", "Project name").option("-y, --yes", "Skip prompts and use defaults").option("--ts, --typescript", "Use TypeScript (default)").option("--js, --javascript", "Use JavaScript").option("--esm", "Use ES Modules (import/export) - default").option("--cjs, --commonjs", "Use CommonJS (require/module.exports)").option("--db <database>", "Database type (postgresql, mysql, sqlite, mongodb, none)").action(
|
|
77
77
|
async (name, cmdOptions) => {
|
|
78
78
|
console.log(
|
|
79
79
|
chalk2.blue(`
|
|
@@ -90,6 +90,7 @@ var initCommand = new Command("init").alias("new").description("Initialize a new
|
|
|
90
90
|
options = {
|
|
91
91
|
name: name || "my-servcraft-app",
|
|
92
92
|
language: cmdOptions.javascript ? "javascript" : "typescript",
|
|
93
|
+
moduleSystem: cmdOptions.commonjs ? "commonjs" : "esm",
|
|
93
94
|
database: db,
|
|
94
95
|
orm: db === "mongodb" ? "mongoose" : db === "none" ? "none" : "prisma",
|
|
95
96
|
validator: "zod",
|
|
@@ -119,6 +120,16 @@ var initCommand = new Command("init").alias("new").description("Initialize a new
|
|
|
119
120
|
],
|
|
120
121
|
default: "typescript"
|
|
121
122
|
},
|
|
123
|
+
{
|
|
124
|
+
type: "list",
|
|
125
|
+
name: "moduleSystem",
|
|
126
|
+
message: "Select module system:",
|
|
127
|
+
choices: [
|
|
128
|
+
{ name: "ESM (import/export) - Recommended", value: "esm" },
|
|
129
|
+
{ name: "CommonJS (require/module.exports)", value: "commonjs" }
|
|
130
|
+
],
|
|
131
|
+
default: "esm"
|
|
132
|
+
},
|
|
122
133
|
{
|
|
123
134
|
type: "list",
|
|
124
135
|
name: "database",
|
|
@@ -181,10 +192,10 @@ var initCommand = new Command("init").alias("new").description("Initialize a new
|
|
|
181
192
|
JSON.stringify(packageJson, null, 2)
|
|
182
193
|
);
|
|
183
194
|
if (options.language === "typescript") {
|
|
184
|
-
await writeFile(path2.join(projectDir, "tsconfig.json"), generateTsConfig());
|
|
185
|
-
await writeFile(path2.join(projectDir, "tsup.config.ts"), generateTsupConfig());
|
|
195
|
+
await writeFile(path2.join(projectDir, "tsconfig.json"), generateTsConfig(options));
|
|
196
|
+
await writeFile(path2.join(projectDir, "tsup.config.ts"), generateTsupConfig(options));
|
|
186
197
|
} else {
|
|
187
|
-
await writeFile(path2.join(projectDir, "jsconfig.json"), generateJsConfig());
|
|
198
|
+
await writeFile(path2.join(projectDir, "jsconfig.json"), generateJsConfig(options));
|
|
188
199
|
}
|
|
189
200
|
await writeFile(path2.join(projectDir, ".env.example"), generateEnvExample(options));
|
|
190
201
|
await writeFile(path2.join(projectDir, ".env"), generateEnvExample(options));
|
|
@@ -194,7 +205,7 @@ var initCommand = new Command("init").alias("new").description("Initialize a new
|
|
|
194
205
|
path2.join(projectDir, "docker-compose.yml"),
|
|
195
206
|
generateDockerCompose(options)
|
|
196
207
|
);
|
|
197
|
-
const ext = options.language === "typescript" ? "ts" : "js";
|
|
208
|
+
const ext = options.language === "typescript" ? "ts" : options.moduleSystem === "esm" ? "js" : "cjs";
|
|
198
209
|
const dirs = [
|
|
199
210
|
"src/core",
|
|
200
211
|
"src/config",
|
|
@@ -223,6 +234,22 @@ var initCommand = new Command("init").alias("new").description("Initialize a new
|
|
|
223
234
|
path2.join(projectDir, `src/core/logger.${ext}`),
|
|
224
235
|
generateLoggerFile(options)
|
|
225
236
|
);
|
|
237
|
+
await writeFile(
|
|
238
|
+
path2.join(projectDir, `src/config/index.${ext}`),
|
|
239
|
+
generateConfigFile(options)
|
|
240
|
+
);
|
|
241
|
+
await writeFile(
|
|
242
|
+
path2.join(projectDir, `src/middleware/index.${ext}`),
|
|
243
|
+
generateMiddlewareFile(options)
|
|
244
|
+
);
|
|
245
|
+
await writeFile(
|
|
246
|
+
path2.join(projectDir, `src/utils/index.${ext}`),
|
|
247
|
+
generateUtilsFile(options)
|
|
248
|
+
);
|
|
249
|
+
await writeFile(
|
|
250
|
+
path2.join(projectDir, `src/types/index.${ext}`),
|
|
251
|
+
generateTypesFile(options)
|
|
252
|
+
);
|
|
226
253
|
if (options.orm === "prisma") {
|
|
227
254
|
await writeFile(
|
|
228
255
|
path2.join(projectDir, "prisma/schema.prisma"),
|
|
@@ -284,18 +311,35 @@ var initCommand = new Command("init").alias("new").description("Initialize a new
|
|
|
284
311
|
);
|
|
285
312
|
function generatePackageJson(options) {
|
|
286
313
|
const isTS = options.language === "typescript";
|
|
314
|
+
const isESM = options.moduleSystem === "esm";
|
|
315
|
+
let devCommand;
|
|
316
|
+
if (isTS) {
|
|
317
|
+
devCommand = "tsx watch src/index.ts";
|
|
318
|
+
} else if (isESM) {
|
|
319
|
+
devCommand = "node --watch src/index.js";
|
|
320
|
+
} else {
|
|
321
|
+
devCommand = "node --watch src/index.cjs";
|
|
322
|
+
}
|
|
323
|
+
let startCommand;
|
|
324
|
+
if (isTS) {
|
|
325
|
+
startCommand = isESM ? "node dist/index.js" : "node dist/index.cjs";
|
|
326
|
+
} else if (isESM) {
|
|
327
|
+
startCommand = "node src/index.js";
|
|
328
|
+
} else {
|
|
329
|
+
startCommand = "node src/index.cjs";
|
|
330
|
+
}
|
|
287
331
|
const pkg = {
|
|
288
332
|
name: options.name,
|
|
289
333
|
version: "0.1.0",
|
|
290
334
|
description: "A Servcraft application",
|
|
291
|
-
main: isTS ? "dist/index.js" : "src/index.js",
|
|
292
|
-
type: "module",
|
|
335
|
+
main: isTS ? isESM ? "dist/index.js" : "dist/index.cjs" : isESM ? "src/index.js" : "src/index.cjs",
|
|
336
|
+
...isESM && { type: "module" },
|
|
293
337
|
scripts: {
|
|
294
|
-
dev:
|
|
338
|
+
dev: devCommand,
|
|
295
339
|
build: isTS ? "tsup" : 'echo "No build needed for JS"',
|
|
296
|
-
start:
|
|
340
|
+
start: startCommand,
|
|
297
341
|
test: "vitest",
|
|
298
|
-
lint: isTS ? "eslint src --ext .ts" : "eslint src --ext .js"
|
|
342
|
+
lint: isTS ? "eslint src --ext .ts" : "eslint src --ext .js,.cjs"
|
|
299
343
|
},
|
|
300
344
|
dependencies: {
|
|
301
345
|
fastify: "^4.28.1",
|
|
@@ -357,13 +401,14 @@ function generatePackageJson(options) {
|
|
|
357
401
|
}
|
|
358
402
|
return pkg;
|
|
359
403
|
}
|
|
360
|
-
function generateTsConfig() {
|
|
404
|
+
function generateTsConfig(options) {
|
|
405
|
+
const isESM = options.moduleSystem === "esm";
|
|
361
406
|
return JSON.stringify(
|
|
362
407
|
{
|
|
363
408
|
compilerOptions: {
|
|
364
409
|
target: "ES2022",
|
|
365
|
-
module: "NodeNext",
|
|
366
|
-
moduleResolution: "NodeNext",
|
|
410
|
+
module: isESM ? "NodeNext" : "CommonJS",
|
|
411
|
+
moduleResolution: isESM ? "NodeNext" : "Node",
|
|
367
412
|
lib: ["ES2022"],
|
|
368
413
|
outDir: "./dist",
|
|
369
414
|
rootDir: "./src",
|
|
@@ -382,12 +427,13 @@ function generateTsConfig() {
|
|
|
382
427
|
2
|
|
383
428
|
);
|
|
384
429
|
}
|
|
385
|
-
function generateJsConfig() {
|
|
430
|
+
function generateJsConfig(options) {
|
|
431
|
+
const isESM = options.moduleSystem === "esm";
|
|
386
432
|
return JSON.stringify(
|
|
387
433
|
{
|
|
388
434
|
compilerOptions: {
|
|
389
|
-
module: "NodeNext",
|
|
390
|
-
moduleResolution: "NodeNext",
|
|
435
|
+
module: isESM ? "NodeNext" : "CommonJS",
|
|
436
|
+
moduleResolution: isESM ? "NodeNext" : "Node",
|
|
391
437
|
target: "ES2022",
|
|
392
438
|
checkJs: true
|
|
393
439
|
},
|
|
@@ -398,12 +444,13 @@ function generateJsConfig() {
|
|
|
398
444
|
2
|
|
399
445
|
);
|
|
400
446
|
}
|
|
401
|
-
function generateTsupConfig() {
|
|
447
|
+
function generateTsupConfig(options) {
|
|
448
|
+
const isESM = options.moduleSystem === "esm";
|
|
402
449
|
return `import { defineConfig } from 'tsup';
|
|
403
450
|
|
|
404
451
|
export default defineConfig({
|
|
405
452
|
entry: ['src/index.ts'],
|
|
406
|
-
format: ['esm'],
|
|
453
|
+
format: ['${isESM ? "esm" : "cjs"}'],
|
|
407
454
|
dts: true,
|
|
408
455
|
clean: true,
|
|
409
456
|
sourcemap: true,
|
|
@@ -412,7 +459,7 @@ export default defineConfig({
|
|
|
412
459
|
`;
|
|
413
460
|
}
|
|
414
461
|
function generateEnvExample(options) {
|
|
415
|
-
let
|
|
462
|
+
let env2 = `# Server
|
|
416
463
|
NODE_ENV=development
|
|
417
464
|
PORT=3000
|
|
418
465
|
HOST=0.0.0.0
|
|
@@ -430,31 +477,31 @@ RATE_LIMIT_MAX=100
|
|
|
430
477
|
LOG_LEVEL=info
|
|
431
478
|
`;
|
|
432
479
|
if (options.database === "postgresql") {
|
|
433
|
-
|
|
480
|
+
env2 += `
|
|
434
481
|
# Database (PostgreSQL)
|
|
435
482
|
DATABASE_PROVIDER=postgresql
|
|
436
483
|
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
|
|
437
484
|
`;
|
|
438
485
|
} else if (options.database === "mysql") {
|
|
439
|
-
|
|
486
|
+
env2 += `
|
|
440
487
|
# Database (MySQL)
|
|
441
488
|
DATABASE_PROVIDER=mysql
|
|
442
489
|
DATABASE_URL="mysql://user:password@localhost:3306/mydb"
|
|
443
490
|
`;
|
|
444
491
|
} else if (options.database === "sqlite") {
|
|
445
|
-
|
|
492
|
+
env2 += `
|
|
446
493
|
# Database (SQLite)
|
|
447
494
|
DATABASE_PROVIDER=sqlite
|
|
448
495
|
DATABASE_URL="file:./dev.db"
|
|
449
496
|
`;
|
|
450
497
|
} else if (options.database === "mongodb") {
|
|
451
|
-
|
|
498
|
+
env2 += `
|
|
452
499
|
# Database (MongoDB)
|
|
453
500
|
MONGODB_URI="mongodb://localhost:27017/mydb"
|
|
454
501
|
`;
|
|
455
502
|
}
|
|
456
503
|
if (options.features.includes("email")) {
|
|
457
|
-
|
|
504
|
+
env2 += `
|
|
458
505
|
# Email
|
|
459
506
|
SMTP_HOST=smtp.example.com
|
|
460
507
|
SMTP_PORT=587
|
|
@@ -464,12 +511,12 @@ SMTP_FROM="App <noreply@example.com>"
|
|
|
464
511
|
`;
|
|
465
512
|
}
|
|
466
513
|
if (options.features.includes("redis")) {
|
|
467
|
-
|
|
514
|
+
env2 += `
|
|
468
515
|
# Redis
|
|
469
516
|
REDIS_URL=redis://localhost:6379
|
|
470
517
|
`;
|
|
471
518
|
}
|
|
472
|
-
return
|
|
519
|
+
return env2;
|
|
473
520
|
}
|
|
474
521
|
function generateGitignore() {
|
|
475
522
|
return `node_modules/
|
|
@@ -598,7 +645,12 @@ model User {
|
|
|
598
645
|
}
|
|
599
646
|
function generateEntryFile(options) {
|
|
600
647
|
const isTS = options.language === "typescript";
|
|
601
|
-
|
|
648
|
+
const isESM = options.moduleSystem === "esm";
|
|
649
|
+
const fileExt = isTS ? "js" : isESM ? "js" : "cjs";
|
|
650
|
+
if (isESM || isTS) {
|
|
651
|
+
return `import 'dotenv/config';
|
|
652
|
+
import { createServer } from './core/server.${fileExt}';
|
|
653
|
+
import { logger } from './core/logger.${fileExt}';
|
|
602
654
|
|
|
603
655
|
async function main()${isTS ? ": Promise<void>" : ""} {
|
|
604
656
|
const server = createServer();
|
|
@@ -613,16 +665,31 @@ async function main()${isTS ? ": Promise<void>" : ""} {
|
|
|
613
665
|
|
|
614
666
|
main();
|
|
615
667
|
`;
|
|
668
|
+
} else {
|
|
669
|
+
return `require('dotenv').config();
|
|
670
|
+
const { createServer } = require('./core/server.cjs');
|
|
671
|
+
const { logger } = require('./core/logger.cjs');
|
|
672
|
+
|
|
673
|
+
async function main() {
|
|
674
|
+
const server = createServer();
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
await server.start();
|
|
678
|
+
} catch (error) {
|
|
679
|
+
logger.error({ err: error }, 'Failed to start server');
|
|
680
|
+
process.exit(1);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
main();
|
|
685
|
+
`;
|
|
686
|
+
}
|
|
616
687
|
}
|
|
617
688
|
function generateServerFile(options) {
|
|
618
689
|
const isTS = options.language === "typescript";
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
const { logger } = require('./logger.js');`}
|
|
623
|
-
|
|
624
|
-
${isTS ? "export function createServer(): { instance: FastifyInstance; start: () => Promise<void> }" : "function createServer()"} {
|
|
625
|
-
const app = Fastify({ logger });
|
|
690
|
+
const isESM = options.moduleSystem === "esm";
|
|
691
|
+
const fileExt = isTS ? "js" : isESM ? "js" : "cjs";
|
|
692
|
+
const serverBody = ` const app = Fastify({ logger });
|
|
626
693
|
|
|
627
694
|
// Health check
|
|
628
695
|
app.get('/health', async () => ({
|
|
@@ -649,33 +716,58 @@ ${isTS ? "export function createServer(): { instance: FastifyInstance; start: ()
|
|
|
649
716
|
logger.info(\`Server listening on \${host}:\${port}\`);
|
|
650
717
|
},
|
|
651
718
|
};
|
|
652
|
-
}
|
|
719
|
+
}`;
|
|
720
|
+
if (isESM || isTS) {
|
|
721
|
+
return `import Fastify from 'fastify';
|
|
722
|
+
${isTS ? "import type { FastifyInstance } from 'fastify';" : ""}
|
|
723
|
+
import { logger } from './logger.${fileExt}';
|
|
724
|
+
|
|
725
|
+
${isTS ? "export function createServer(): { instance: FastifyInstance; start: () => Promise<void> }" : "export function createServer()"} {
|
|
726
|
+
${serverBody}
|
|
727
|
+
`;
|
|
728
|
+
} else {
|
|
729
|
+
return `const Fastify = require('fastify');
|
|
730
|
+
const { logger } = require('./logger.cjs');
|
|
731
|
+
|
|
732
|
+
function createServer() {
|
|
733
|
+
${serverBody}
|
|
653
734
|
|
|
654
|
-
|
|
735
|
+
module.exports = { createServer };
|
|
655
736
|
`;
|
|
737
|
+
}
|
|
656
738
|
}
|
|
657
739
|
function generateLoggerFile(options) {
|
|
658
740
|
const isTS = options.language === "typescript";
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
${isTS ? "export const logger: Logger" : "const logger"} = pino({
|
|
741
|
+
const isESM = options.moduleSystem === "esm";
|
|
742
|
+
const loggerBody = `pino({
|
|
662
743
|
level: process.env.LOG_LEVEL || 'info',
|
|
663
744
|
transport: process.env.NODE_ENV !== 'production' ? {
|
|
664
745
|
target: 'pino-pretty',
|
|
665
746
|
options: { colorize: true },
|
|
666
747
|
} : undefined,
|
|
667
|
-
})
|
|
748
|
+
})`;
|
|
749
|
+
if (isESM || isTS) {
|
|
750
|
+
return `import pino from 'pino';
|
|
751
|
+
${isTS ? "import type { Logger } from 'pino';" : ""}
|
|
752
|
+
|
|
753
|
+
export const logger${isTS ? ": Logger" : ""} = ${loggerBody};
|
|
754
|
+
`;
|
|
755
|
+
} else {
|
|
756
|
+
return `const pino = require('pino');
|
|
757
|
+
|
|
758
|
+
const logger = ${loggerBody};
|
|
668
759
|
|
|
669
|
-
|
|
760
|
+
module.exports = { logger };
|
|
670
761
|
`;
|
|
762
|
+
}
|
|
671
763
|
}
|
|
672
764
|
function generateMongooseConnection(options) {
|
|
673
765
|
const isTS = options.language === "typescript";
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/mydb';
|
|
766
|
+
const isESM = options.moduleSystem === "esm";
|
|
767
|
+
const fileExt = isTS ? "js" : isESM ? "js" : "cjs";
|
|
768
|
+
const connectionBody = `const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/mydb';
|
|
677
769
|
|
|
678
|
-
${isTS ? "
|
|
770
|
+
async function connectDatabase()${isTS ? ": Promise<typeof mongoose>" : ""} {
|
|
679
771
|
try {
|
|
680
772
|
const conn = await mongoose.connect(MONGODB_URI);
|
|
681
773
|
logger.info(\`MongoDB connected: \${conn.connection.host}\`);
|
|
@@ -686,35 +778,36 @@ ${isTS ? "export async function connectDatabase(): Promise<typeof mongoose>" : "
|
|
|
686
778
|
}
|
|
687
779
|
}
|
|
688
780
|
|
|
689
|
-
${isTS ? "
|
|
781
|
+
async function disconnectDatabase()${isTS ? ": Promise<void>" : ""} {
|
|
690
782
|
try {
|
|
691
783
|
await mongoose.disconnect();
|
|
692
784
|
logger.info('MongoDB disconnected');
|
|
693
785
|
} catch (error) {
|
|
694
786
|
logger.error({ err: error }, 'MongoDB disconnect failed');
|
|
695
787
|
}
|
|
696
|
-
}
|
|
788
|
+
}`;
|
|
789
|
+
if (isESM || isTS) {
|
|
790
|
+
return `import mongoose from 'mongoose';
|
|
791
|
+
import { logger } from '../core/logger.${fileExt}';
|
|
792
|
+
|
|
793
|
+
${connectionBody}
|
|
794
|
+
|
|
795
|
+
export { connectDatabase, disconnectDatabase, mongoose };
|
|
796
|
+
`;
|
|
797
|
+
} else {
|
|
798
|
+
return `const mongoose = require('mongoose');
|
|
799
|
+
const { logger } = require('../core/logger.cjs');
|
|
697
800
|
|
|
698
|
-
${
|
|
801
|
+
${connectionBody}
|
|
802
|
+
|
|
803
|
+
module.exports = { connectDatabase, disconnectDatabase, mongoose };
|
|
699
804
|
`;
|
|
805
|
+
}
|
|
700
806
|
}
|
|
701
807
|
function generateMongooseUserModel(options) {
|
|
702
808
|
const isTS = options.language === "typescript";
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
${isTS ? `export interface IUser extends Document {
|
|
706
|
-
email: string;
|
|
707
|
-
password: string;
|
|
708
|
-
name?: string;
|
|
709
|
-
role: 'user' | 'admin';
|
|
710
|
-
status: 'active' | 'inactive' | 'suspended';
|
|
711
|
-
emailVerified: boolean;
|
|
712
|
-
createdAt: Date;
|
|
713
|
-
updatedAt: Date;
|
|
714
|
-
comparePassword(candidatePassword: string): Promise<boolean>;
|
|
715
|
-
}` : ""}
|
|
716
|
-
|
|
717
|
-
const userSchema = new Schema${isTS ? "<IUser>" : ""}({
|
|
809
|
+
const isESM = options.moduleSystem === "esm";
|
|
810
|
+
const schemaBody = `const userSchema = new Schema${isTS ? "<IUser>" : ""}({
|
|
718
811
|
email: {
|
|
719
812
|
type: String,
|
|
720
813
|
required: true,
|
|
@@ -765,9 +858,237 @@ userSchema.pre('save', async function(next) {
|
|
|
765
858
|
// Compare password method
|
|
766
859
|
userSchema.methods.comparePassword = async function(candidatePassword${isTS ? ": string" : ""})${isTS ? ": Promise<boolean>" : ""} {
|
|
767
860
|
return bcrypt.compare(candidatePassword, this.password);
|
|
768
|
-
}
|
|
861
|
+
};`;
|
|
862
|
+
const tsInterface = isTS ? `
|
|
863
|
+
export interface IUser extends Document {
|
|
864
|
+
email: string;
|
|
865
|
+
password: string;
|
|
866
|
+
name?: string;
|
|
867
|
+
role: 'user' | 'admin';
|
|
868
|
+
status: 'active' | 'inactive' | 'suspended';
|
|
869
|
+
emailVerified: boolean;
|
|
870
|
+
createdAt: Date;
|
|
871
|
+
updatedAt: Date;
|
|
872
|
+
comparePassword(candidatePassword: string): Promise<boolean>;
|
|
873
|
+
}
|
|
874
|
+
` : "";
|
|
875
|
+
if (isESM || isTS) {
|
|
876
|
+
return `import mongoose${isTS ? ", { Schema, Document }" : ""} from 'mongoose';
|
|
877
|
+
import bcrypt from 'bcryptjs';
|
|
878
|
+
${!isTS ? "const { Schema } = mongoose;" : ""}
|
|
879
|
+
${tsInterface}
|
|
880
|
+
${schemaBody}
|
|
881
|
+
|
|
882
|
+
export const User = mongoose.model${isTS ? "<IUser>" : ""}('User', userSchema);
|
|
883
|
+
`;
|
|
884
|
+
} else {
|
|
885
|
+
return `const mongoose = require('mongoose');
|
|
886
|
+
const bcrypt = require('bcryptjs');
|
|
887
|
+
const { Schema } = mongoose;
|
|
888
|
+
|
|
889
|
+
${schemaBody}
|
|
890
|
+
|
|
891
|
+
const User = mongoose.model('User', userSchema);
|
|
892
|
+
|
|
893
|
+
module.exports = { User };
|
|
894
|
+
`;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
function generateConfigFile(options) {
|
|
898
|
+
const isTS = options.language === "typescript";
|
|
899
|
+
const isESM = options.moduleSystem === "esm";
|
|
900
|
+
const configBody = `{
|
|
901
|
+
env: process.env.NODE_ENV || 'development',
|
|
902
|
+
port: parseInt(process.env.PORT || '3000', 10),
|
|
903
|
+
host: process.env.HOST || '0.0.0.0',
|
|
904
|
+
|
|
905
|
+
jwt: {
|
|
906
|
+
secret: process.env.JWT_SECRET || 'your-secret-key',
|
|
907
|
+
accessExpiresIn: process.env.JWT_ACCESS_EXPIRES_IN || '15m',
|
|
908
|
+
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
|
909
|
+
},
|
|
910
|
+
|
|
911
|
+
cors: {
|
|
912
|
+
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
|
913
|
+
},
|
|
914
|
+
|
|
915
|
+
rateLimit: {
|
|
916
|
+
max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
|
|
917
|
+
},
|
|
918
|
+
|
|
919
|
+
log: {
|
|
920
|
+
level: process.env.LOG_LEVEL || 'info',
|
|
921
|
+
},
|
|
922
|
+
}${isTS ? " as const" : ""}`;
|
|
923
|
+
if (isESM || isTS) {
|
|
924
|
+
return `import 'dotenv/config';
|
|
925
|
+
|
|
926
|
+
export const config = ${configBody};
|
|
927
|
+
`;
|
|
928
|
+
} else {
|
|
929
|
+
return `require('dotenv').config();
|
|
930
|
+
|
|
931
|
+
const config = ${configBody};
|
|
932
|
+
|
|
933
|
+
module.exports = { config };
|
|
934
|
+
`;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
function generateMiddlewareFile(options) {
|
|
938
|
+
const isTS = options.language === "typescript";
|
|
939
|
+
const isESM = options.moduleSystem === "esm";
|
|
940
|
+
const fileExt = isTS ? "js" : isESM ? "js" : "cjs";
|
|
941
|
+
const middlewareBody = `/**
|
|
942
|
+
* Error handler middleware
|
|
943
|
+
*/
|
|
944
|
+
function errorHandler(error${isTS ? ": Error" : ""}, request${isTS ? ": FastifyRequest" : ""}, reply${isTS ? ": FastifyReply" : ""})${isTS ? ": void" : ""} {
|
|
945
|
+
logger.error({ err: error, url: request.url, method: request.method }, 'Request error');
|
|
946
|
+
|
|
947
|
+
const statusCode = (error${isTS ? " as any" : ""}).statusCode || 500;
|
|
948
|
+
const message = statusCode === 500 ? 'Internal Server Error' : error.message;
|
|
949
|
+
|
|
950
|
+
reply.status(statusCode).send({
|
|
951
|
+
success: false,
|
|
952
|
+
error: message,
|
|
953
|
+
...(process.env.NODE_ENV === 'development' && { stack: error.stack }),
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Request logging middleware
|
|
959
|
+
*/
|
|
960
|
+
function requestLogger(request${isTS ? ": FastifyRequest" : ""}, reply${isTS ? ": FastifyReply" : ""}, done${isTS ? ": () => void" : ""})${isTS ? ": void" : ""} {
|
|
961
|
+
logger.info({ url: request.url, method: request.method, ip: request.ip }, 'Incoming request');
|
|
962
|
+
done();
|
|
963
|
+
}`;
|
|
964
|
+
if (isESM || isTS) {
|
|
965
|
+
return `${isTS ? "import type { FastifyRequest, FastifyReply } from 'fastify';" : ""}
|
|
966
|
+
import { logger } from '../core/logger.${fileExt}';
|
|
967
|
+
|
|
968
|
+
${middlewareBody}
|
|
969
|
+
|
|
970
|
+
export { errorHandler, requestLogger };
|
|
971
|
+
`;
|
|
972
|
+
} else {
|
|
973
|
+
return `const { logger } = require('../core/logger.cjs');
|
|
974
|
+
|
|
975
|
+
${middlewareBody}
|
|
976
|
+
|
|
977
|
+
module.exports = { errorHandler, requestLogger };
|
|
978
|
+
`;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function generateUtilsFile(options) {
|
|
982
|
+
const isTS = options.language === "typescript";
|
|
983
|
+
const isESM = options.moduleSystem === "esm";
|
|
984
|
+
const utilsBody = `/**
|
|
985
|
+
* Standard API response helper
|
|
986
|
+
*/
|
|
987
|
+
function apiResponse${isTS ? "<T>" : ""}(data${isTS ? ": T" : ""}, message = "Success")${isTS ? ": { success: boolean; message: string; data: T }" : ""} {
|
|
988
|
+
return {
|
|
989
|
+
success: true,
|
|
990
|
+
message,
|
|
991
|
+
data,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/**
|
|
996
|
+
* Error response helper
|
|
997
|
+
*/
|
|
998
|
+
function errorResponse(message${isTS ? ": string" : ""}, code${isTS ? "?: string" : ""})${isTS ? ": { success: boolean; error: string; code?: string }" : ""} {
|
|
999
|
+
return {
|
|
1000
|
+
success: false,
|
|
1001
|
+
error: message,
|
|
1002
|
+
...(code && { code }),
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/**
|
|
1007
|
+
* Pagination helper
|
|
1008
|
+
*/
|
|
1009
|
+
function paginate${isTS ? "<T>" : ""}(data${isTS ? ": T[]" : ""}, page${isTS ? ": number" : ""}, limit${isTS ? ": number" : ""}, total${isTS ? ": number" : ""})${isTS ? ": PaginationResult<T>" : ""} {
|
|
1010
|
+
const totalPages = Math.ceil(total / limit);
|
|
1011
|
+
|
|
1012
|
+
return {
|
|
1013
|
+
data,
|
|
1014
|
+
pagination: {
|
|
1015
|
+
page,
|
|
1016
|
+
limit,
|
|
1017
|
+
total,
|
|
1018
|
+
totalPages,
|
|
1019
|
+
hasNextPage: page < totalPages,
|
|
1020
|
+
hasPrevPage: page > 1,
|
|
1021
|
+
},
|
|
1022
|
+
};
|
|
1023
|
+
}`;
|
|
1024
|
+
const tsInterface = isTS ? `
|
|
1025
|
+
/**
|
|
1026
|
+
* Pagination result type
|
|
1027
|
+
*/
|
|
1028
|
+
export interface PaginationResult<T> {
|
|
1029
|
+
data: T[];
|
|
1030
|
+
pagination: {
|
|
1031
|
+
page: number;
|
|
1032
|
+
limit: number;
|
|
1033
|
+
total: number;
|
|
1034
|
+
totalPages: number;
|
|
1035
|
+
hasNextPage: boolean;
|
|
1036
|
+
hasPrevPage: boolean;
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
` : "";
|
|
1040
|
+
if (isESM || isTS) {
|
|
1041
|
+
return `${tsInterface}
|
|
1042
|
+
${utilsBody}
|
|
1043
|
+
|
|
1044
|
+
export { apiResponse, errorResponse, paginate };
|
|
1045
|
+
`;
|
|
1046
|
+
} else {
|
|
1047
|
+
return `${utilsBody}
|
|
1048
|
+
|
|
1049
|
+
module.exports = { apiResponse, errorResponse, paginate };
|
|
1050
|
+
`;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
function generateTypesFile(options) {
|
|
1054
|
+
if (options.language !== "typescript") {
|
|
1055
|
+
return "// Types file - not needed for JavaScript\n";
|
|
1056
|
+
}
|
|
1057
|
+
return `/**
|
|
1058
|
+
* Common type definitions
|
|
1059
|
+
*/
|
|
769
1060
|
|
|
770
|
-
|
|
1061
|
+
export interface ApiResponse<T = unknown> {
|
|
1062
|
+
success: boolean;
|
|
1063
|
+
message?: string;
|
|
1064
|
+
data?: T;
|
|
1065
|
+
error?: string;
|
|
1066
|
+
code?: string;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
export interface PaginatedResponse<T> {
|
|
1070
|
+
data: T[];
|
|
1071
|
+
pagination: {
|
|
1072
|
+
page: number;
|
|
1073
|
+
limit: number;
|
|
1074
|
+
total: number;
|
|
1075
|
+
totalPages: number;
|
|
1076
|
+
hasNextPage: boolean;
|
|
1077
|
+
hasPrevPage: boolean;
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
export interface RequestUser {
|
|
1082
|
+
id: string;
|
|
1083
|
+
email: string;
|
|
1084
|
+
role: string;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
declare module 'fastify' {
|
|
1088
|
+
interface FastifyRequest {
|
|
1089
|
+
user?: RequestUser;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
771
1092
|
`;
|
|
772
1093
|
}
|
|
773
1094
|
|
|
@@ -1936,12 +2257,12 @@ var EnvManager = class {
|
|
|
1936
2257
|
async addVariables(sections) {
|
|
1937
2258
|
const added = [];
|
|
1938
2259
|
const skipped = [];
|
|
1939
|
-
let
|
|
2260
|
+
let created2 = false;
|
|
1940
2261
|
let envContent = "";
|
|
1941
2262
|
if (existsSync(this.envPath)) {
|
|
1942
2263
|
envContent = await fs3.readFile(this.envPath, "utf-8");
|
|
1943
2264
|
} else {
|
|
1944
|
-
|
|
2265
|
+
created2 = true;
|
|
1945
2266
|
}
|
|
1946
2267
|
const existingKeys = this.parseExistingKeys(envContent);
|
|
1947
2268
|
let newContent = envContent;
|
|
@@ -1972,7 +2293,7 @@ var EnvManager = class {
|
|
|
1972
2293
|
if (existsSync(this.envExamplePath)) {
|
|
1973
2294
|
await this.updateEnvExample(sections);
|
|
1974
2295
|
}
|
|
1975
|
-
return { added, skipped, created };
|
|
2296
|
+
return { added, skipped, created: created2 };
|
|
1976
2297
|
}
|
|
1977
2298
|
/**
|
|
1978
2299
|
* Update .env.example file
|
|
@@ -3532,11 +3853,43 @@ export interface ${name.charAt(0).toUpperCase() + name.slice(1)}Data {
|
|
|
3532
3853
|
await writeFile(path6.join(dir, fileName), content);
|
|
3533
3854
|
}
|
|
3534
3855
|
}
|
|
3856
|
+
async function findServercraftModules() {
|
|
3857
|
+
const scriptDir = path6.dirname(new URL(import.meta.url).pathname);
|
|
3858
|
+
const possiblePaths = [
|
|
3859
|
+
// Local node_modules (when servcraft is a dependency)
|
|
3860
|
+
path6.join(process.cwd(), "node_modules", "servcraft", "src", "modules"),
|
|
3861
|
+
// From dist/cli/index.js -> src/modules (npx or global install)
|
|
3862
|
+
path6.resolve(scriptDir, "..", "..", "src", "modules"),
|
|
3863
|
+
// From src/cli/commands/add-module.ts -> src/modules (development)
|
|
3864
|
+
path6.resolve(scriptDir, "..", "..", "modules")
|
|
3865
|
+
];
|
|
3866
|
+
for (const p of possiblePaths) {
|
|
3867
|
+
try {
|
|
3868
|
+
const stats = await fs5.stat(p);
|
|
3869
|
+
if (stats.isDirectory()) {
|
|
3870
|
+
return p;
|
|
3871
|
+
}
|
|
3872
|
+
} catch {
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
return null;
|
|
3876
|
+
}
|
|
3535
3877
|
async function generateModuleFiles(moduleName, moduleDir) {
|
|
3536
|
-
const
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3878
|
+
const moduleNameMap = {
|
|
3879
|
+
users: "user",
|
|
3880
|
+
"rate-limit": "rate-limit",
|
|
3881
|
+
"feature-flag": "feature-flag",
|
|
3882
|
+
"api-versioning": "api-versioning",
|
|
3883
|
+
"media-processing": "media-processing"
|
|
3884
|
+
};
|
|
3885
|
+
const sourceDirName = moduleNameMap[moduleName] || moduleName;
|
|
3886
|
+
const servercraftModulesDir = await findServercraftModules();
|
|
3887
|
+
if (servercraftModulesDir) {
|
|
3888
|
+
const sourceModuleDir = path6.join(servercraftModulesDir, sourceDirName);
|
|
3889
|
+
if (await fileExists(sourceModuleDir)) {
|
|
3890
|
+
await copyModuleFromSource(sourceModuleDir, moduleDir);
|
|
3891
|
+
return;
|
|
3892
|
+
}
|
|
3540
3893
|
}
|
|
3541
3894
|
switch (moduleName) {
|
|
3542
3895
|
case "auth":
|
|
@@ -3792,12 +4145,1782 @@ dbCommand.command("status").description("Show migration status").action(async ()
|
|
|
3792
4145
|
}
|
|
3793
4146
|
});
|
|
3794
4147
|
|
|
4148
|
+
// src/cli/commands/docs.ts
|
|
4149
|
+
import { Command as Command5 } from "commander";
|
|
4150
|
+
import path8 from "path";
|
|
4151
|
+
import fs7 from "fs/promises";
|
|
4152
|
+
import ora6 from "ora";
|
|
4153
|
+
import chalk6 from "chalk";
|
|
4154
|
+
|
|
4155
|
+
// src/cli/utils/docs-generator.ts
|
|
4156
|
+
import fs6 from "fs/promises";
|
|
4157
|
+
import path7 from "path";
|
|
4158
|
+
import ora5 from "ora";
|
|
4159
|
+
|
|
4160
|
+
// src/core/server.ts
|
|
4161
|
+
import Fastify from "fastify";
|
|
4162
|
+
|
|
4163
|
+
// src/core/logger.ts
|
|
4164
|
+
import pino from "pino";
|
|
4165
|
+
var defaultConfig = {
|
|
4166
|
+
level: process.env.LOG_LEVEL || "info",
|
|
4167
|
+
pretty: process.env.NODE_ENV !== "production",
|
|
4168
|
+
name: "servcraft"
|
|
4169
|
+
};
|
|
4170
|
+
function createLogger(config2 = {}) {
|
|
4171
|
+
const mergedConfig = { ...defaultConfig, ...config2 };
|
|
4172
|
+
const transport = mergedConfig.pretty ? {
|
|
4173
|
+
target: "pino-pretty",
|
|
4174
|
+
options: {
|
|
4175
|
+
colorize: true,
|
|
4176
|
+
translateTime: "SYS:standard",
|
|
4177
|
+
ignore: "pid,hostname"
|
|
4178
|
+
}
|
|
4179
|
+
} : void 0;
|
|
4180
|
+
return pino({
|
|
4181
|
+
name: mergedConfig.name,
|
|
4182
|
+
level: mergedConfig.level,
|
|
4183
|
+
transport,
|
|
4184
|
+
formatters: {
|
|
4185
|
+
level: (label) => ({ level: label })
|
|
4186
|
+
},
|
|
4187
|
+
timestamp: pino.stdTimeFunctions.isoTime
|
|
4188
|
+
});
|
|
4189
|
+
}
|
|
4190
|
+
var logger = createLogger();
|
|
4191
|
+
|
|
4192
|
+
// src/core/server.ts
|
|
4193
|
+
var defaultConfig2 = {
|
|
4194
|
+
port: parseInt(process.env.PORT || "3000", 10),
|
|
4195
|
+
host: process.env.HOST || "0.0.0.0",
|
|
4196
|
+
trustProxy: true,
|
|
4197
|
+
bodyLimit: 1048576,
|
|
4198
|
+
// 1MB
|
|
4199
|
+
requestTimeout: 3e4
|
|
4200
|
+
// 30s
|
|
4201
|
+
};
|
|
4202
|
+
var Server = class {
|
|
4203
|
+
app;
|
|
4204
|
+
config;
|
|
4205
|
+
logger;
|
|
4206
|
+
isShuttingDown = false;
|
|
4207
|
+
constructor(config2 = {}) {
|
|
4208
|
+
this.config = { ...defaultConfig2, ...config2 };
|
|
4209
|
+
this.logger = this.config.logger || logger;
|
|
4210
|
+
const fastifyOptions = {
|
|
4211
|
+
logger: this.logger,
|
|
4212
|
+
trustProxy: this.config.trustProxy,
|
|
4213
|
+
bodyLimit: this.config.bodyLimit,
|
|
4214
|
+
requestTimeout: this.config.requestTimeout
|
|
4215
|
+
};
|
|
4216
|
+
this.app = Fastify(fastifyOptions);
|
|
4217
|
+
this.setupHealthCheck();
|
|
4218
|
+
this.setupGracefulShutdown();
|
|
4219
|
+
}
|
|
4220
|
+
get instance() {
|
|
4221
|
+
return this.app;
|
|
4222
|
+
}
|
|
4223
|
+
setupHealthCheck() {
|
|
4224
|
+
this.app.get("/health", async (_request, reply) => {
|
|
4225
|
+
const healthcheck = {
|
|
4226
|
+
status: "ok",
|
|
4227
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4228
|
+
uptime: process.uptime(),
|
|
4229
|
+
memory: process.memoryUsage(),
|
|
4230
|
+
version: process.env.npm_package_version || "0.1.0"
|
|
4231
|
+
};
|
|
4232
|
+
return reply.status(200).send(healthcheck);
|
|
4233
|
+
});
|
|
4234
|
+
this.app.get("/ready", async (_request, reply) => {
|
|
4235
|
+
if (this.isShuttingDown) {
|
|
4236
|
+
return reply.status(503).send({ status: "shutting_down" });
|
|
4237
|
+
}
|
|
4238
|
+
return reply.status(200).send({ status: "ready" });
|
|
4239
|
+
});
|
|
4240
|
+
}
|
|
4241
|
+
setupGracefulShutdown() {
|
|
4242
|
+
const signals = ["SIGINT", "SIGTERM", "SIGQUIT"];
|
|
4243
|
+
signals.forEach((signal) => {
|
|
4244
|
+
process.on(signal, async () => {
|
|
4245
|
+
this.logger.info(`Received ${signal}, starting graceful shutdown...`);
|
|
4246
|
+
await this.shutdown();
|
|
4247
|
+
});
|
|
4248
|
+
});
|
|
4249
|
+
process.on("uncaughtException", async (error2) => {
|
|
4250
|
+
this.logger.error({ err: error2 }, "Uncaught exception");
|
|
4251
|
+
await this.shutdown(1);
|
|
4252
|
+
});
|
|
4253
|
+
process.on("unhandledRejection", async (reason) => {
|
|
4254
|
+
this.logger.error({ err: reason }, "Unhandled rejection");
|
|
4255
|
+
await this.shutdown(1);
|
|
4256
|
+
});
|
|
4257
|
+
}
|
|
4258
|
+
async shutdown(exitCode = 0) {
|
|
4259
|
+
if (this.isShuttingDown) {
|
|
4260
|
+
return;
|
|
4261
|
+
}
|
|
4262
|
+
this.isShuttingDown = true;
|
|
4263
|
+
this.logger.info("Graceful shutdown initiated...");
|
|
4264
|
+
const shutdownTimeout = setTimeout(() => {
|
|
4265
|
+
this.logger.error("Graceful shutdown timeout, forcing exit");
|
|
4266
|
+
process.exit(1);
|
|
4267
|
+
}, 3e4);
|
|
4268
|
+
try {
|
|
4269
|
+
await this.app.close();
|
|
4270
|
+
this.logger.info("Server closed successfully");
|
|
4271
|
+
clearTimeout(shutdownTimeout);
|
|
4272
|
+
process.exit(exitCode);
|
|
4273
|
+
} catch (error2) {
|
|
4274
|
+
this.logger.error({ err: error2 }, "Error during shutdown");
|
|
4275
|
+
clearTimeout(shutdownTimeout);
|
|
4276
|
+
process.exit(1);
|
|
4277
|
+
}
|
|
4278
|
+
}
|
|
4279
|
+
async start() {
|
|
4280
|
+
try {
|
|
4281
|
+
await this.app.listen({
|
|
4282
|
+
port: this.config.port,
|
|
4283
|
+
host: this.config.host
|
|
4284
|
+
});
|
|
4285
|
+
this.logger.info(`Server listening on ${this.config.host}:${this.config.port}`);
|
|
4286
|
+
} catch (error2) {
|
|
4287
|
+
this.logger.error({ err: error2 }, "Failed to start server");
|
|
4288
|
+
throw error2;
|
|
4289
|
+
}
|
|
4290
|
+
}
|
|
4291
|
+
};
|
|
4292
|
+
function createServer(config2 = {}) {
|
|
4293
|
+
return new Server(config2);
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
// src/utils/errors.ts
|
|
4297
|
+
var AppError = class _AppError extends Error {
|
|
4298
|
+
statusCode;
|
|
4299
|
+
isOperational;
|
|
4300
|
+
errors;
|
|
4301
|
+
constructor(message, statusCode = 500, isOperational = true, errors) {
|
|
4302
|
+
super(message);
|
|
4303
|
+
this.statusCode = statusCode;
|
|
4304
|
+
this.isOperational = isOperational;
|
|
4305
|
+
this.errors = errors;
|
|
4306
|
+
Object.setPrototypeOf(this, _AppError.prototype);
|
|
4307
|
+
Error.captureStackTrace(this, this.constructor);
|
|
4308
|
+
}
|
|
4309
|
+
};
|
|
4310
|
+
var NotFoundError = class _NotFoundError extends AppError {
|
|
4311
|
+
constructor(resource = "Resource") {
|
|
4312
|
+
super(`${resource} not found`, 404);
|
|
4313
|
+
Object.setPrototypeOf(this, _NotFoundError.prototype);
|
|
4314
|
+
}
|
|
4315
|
+
};
|
|
4316
|
+
var UnauthorizedError = class _UnauthorizedError extends AppError {
|
|
4317
|
+
constructor(message = "Unauthorized") {
|
|
4318
|
+
super(message, 401);
|
|
4319
|
+
Object.setPrototypeOf(this, _UnauthorizedError.prototype);
|
|
4320
|
+
}
|
|
4321
|
+
};
|
|
4322
|
+
var ForbiddenError = class _ForbiddenError extends AppError {
|
|
4323
|
+
constructor(message = "Forbidden") {
|
|
4324
|
+
super(message, 403);
|
|
4325
|
+
Object.setPrototypeOf(this, _ForbiddenError.prototype);
|
|
4326
|
+
}
|
|
4327
|
+
};
|
|
4328
|
+
var BadRequestError = class _BadRequestError extends AppError {
|
|
4329
|
+
constructor(message = "Bad request", errors) {
|
|
4330
|
+
super(message, 400, true, errors);
|
|
4331
|
+
Object.setPrototypeOf(this, _BadRequestError.prototype);
|
|
4332
|
+
}
|
|
4333
|
+
};
|
|
4334
|
+
var ConflictError = class _ConflictError extends AppError {
|
|
4335
|
+
constructor(message = "Resource already exists") {
|
|
4336
|
+
super(message, 409);
|
|
4337
|
+
Object.setPrototypeOf(this, _ConflictError.prototype);
|
|
4338
|
+
}
|
|
4339
|
+
};
|
|
4340
|
+
var ValidationError = class _ValidationError extends AppError {
|
|
4341
|
+
constructor(errors) {
|
|
4342
|
+
super("Validation failed", 422, true, errors);
|
|
4343
|
+
Object.setPrototypeOf(this, _ValidationError.prototype);
|
|
4344
|
+
}
|
|
4345
|
+
};
|
|
4346
|
+
function isAppError(error2) {
|
|
4347
|
+
return error2 instanceof AppError;
|
|
4348
|
+
}
|
|
4349
|
+
|
|
4350
|
+
// src/config/env.ts
|
|
4351
|
+
import { z } from "zod";
|
|
4352
|
+
import dotenv from "dotenv";
|
|
4353
|
+
dotenv.config();
|
|
4354
|
+
var envSchema = z.object({
|
|
4355
|
+
// Server
|
|
4356
|
+
NODE_ENV: z.enum(["development", "staging", "production", "test"]).default("development"),
|
|
4357
|
+
PORT: z.string().transform(Number).default("3000"),
|
|
4358
|
+
HOST: z.string().default("0.0.0.0"),
|
|
4359
|
+
// Database
|
|
4360
|
+
DATABASE_URL: z.string().optional(),
|
|
4361
|
+
// JWT
|
|
4362
|
+
JWT_SECRET: z.string().min(32).optional(),
|
|
4363
|
+
JWT_ACCESS_EXPIRES_IN: z.string().default("15m"),
|
|
4364
|
+
JWT_REFRESH_EXPIRES_IN: z.string().default("7d"),
|
|
4365
|
+
// Security
|
|
4366
|
+
CORS_ORIGIN: z.string().default("*"),
|
|
4367
|
+
RATE_LIMIT_MAX: z.string().transform(Number).default("100"),
|
|
4368
|
+
RATE_LIMIT_WINDOW_MS: z.string().transform(Number).default("60000"),
|
|
4369
|
+
// Email
|
|
4370
|
+
SMTP_HOST: z.string().optional(),
|
|
4371
|
+
SMTP_PORT: z.string().transform(Number).optional(),
|
|
4372
|
+
SMTP_USER: z.string().optional(),
|
|
4373
|
+
SMTP_PASS: z.string().optional(),
|
|
4374
|
+
SMTP_FROM: z.string().optional(),
|
|
4375
|
+
// Redis (optional)
|
|
4376
|
+
REDIS_URL: z.string().optional(),
|
|
4377
|
+
// Logging
|
|
4378
|
+
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info")
|
|
4379
|
+
});
|
|
4380
|
+
function validateEnv() {
|
|
4381
|
+
const parsed = envSchema.safeParse(process.env);
|
|
4382
|
+
if (!parsed.success) {
|
|
4383
|
+
logger.error({ errors: parsed.error.flatten().fieldErrors }, "Invalid environment variables");
|
|
4384
|
+
throw new Error("Invalid environment variables");
|
|
4385
|
+
}
|
|
4386
|
+
return parsed.data;
|
|
4387
|
+
}
|
|
4388
|
+
var env = validateEnv();
|
|
4389
|
+
function isProduction() {
|
|
4390
|
+
return env.NODE_ENV === "production";
|
|
4391
|
+
}
|
|
4392
|
+
|
|
4393
|
+
// src/config/index.ts
|
|
4394
|
+
function parseCorsOrigin(origin) {
|
|
4395
|
+
if (origin === "*") return "*";
|
|
4396
|
+
if (origin.includes(",")) {
|
|
4397
|
+
return origin.split(",").map((o) => o.trim());
|
|
4398
|
+
}
|
|
4399
|
+
return origin;
|
|
4400
|
+
}
|
|
4401
|
+
function createConfig() {
|
|
4402
|
+
return {
|
|
4403
|
+
env,
|
|
4404
|
+
server: {
|
|
4405
|
+
port: env.PORT,
|
|
4406
|
+
host: env.HOST
|
|
4407
|
+
},
|
|
4408
|
+
jwt: {
|
|
4409
|
+
secret: env.JWT_SECRET || "change-me-in-production-please-32chars",
|
|
4410
|
+
accessExpiresIn: env.JWT_ACCESS_EXPIRES_IN,
|
|
4411
|
+
refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN
|
|
4412
|
+
},
|
|
4413
|
+
security: {
|
|
4414
|
+
corsOrigin: parseCorsOrigin(env.CORS_ORIGIN),
|
|
4415
|
+
rateLimit: {
|
|
4416
|
+
max: env.RATE_LIMIT_MAX,
|
|
4417
|
+
windowMs: env.RATE_LIMIT_WINDOW_MS
|
|
4418
|
+
}
|
|
4419
|
+
},
|
|
4420
|
+
email: {
|
|
4421
|
+
host: env.SMTP_HOST,
|
|
4422
|
+
port: env.SMTP_PORT,
|
|
4423
|
+
user: env.SMTP_USER,
|
|
4424
|
+
pass: env.SMTP_PASS,
|
|
4425
|
+
from: env.SMTP_FROM
|
|
4426
|
+
},
|
|
4427
|
+
database: {
|
|
4428
|
+
url: env.DATABASE_URL
|
|
4429
|
+
},
|
|
4430
|
+
redis: {
|
|
4431
|
+
url: env.REDIS_URL
|
|
4432
|
+
}
|
|
4433
|
+
};
|
|
4434
|
+
}
|
|
4435
|
+
var config = createConfig();
|
|
4436
|
+
|
|
4437
|
+
// src/middleware/error-handler.ts
|
|
4438
|
+
function registerErrorHandler(app) {
|
|
4439
|
+
app.setErrorHandler(
|
|
4440
|
+
(error2, request, reply) => {
|
|
4441
|
+
logger.error(
|
|
4442
|
+
{
|
|
4443
|
+
err: error2,
|
|
4444
|
+
requestId: request.id,
|
|
4445
|
+
method: request.method,
|
|
4446
|
+
url: request.url
|
|
4447
|
+
},
|
|
4448
|
+
"Request error"
|
|
4449
|
+
);
|
|
4450
|
+
if (isAppError(error2)) {
|
|
4451
|
+
return reply.status(error2.statusCode).send({
|
|
4452
|
+
success: false,
|
|
4453
|
+
message: error2.message,
|
|
4454
|
+
errors: error2.errors,
|
|
4455
|
+
...isProduction() ? {} : { stack: error2.stack }
|
|
4456
|
+
});
|
|
4457
|
+
}
|
|
4458
|
+
if ("validation" in error2 && error2.validation) {
|
|
4459
|
+
const errors = {};
|
|
4460
|
+
for (const err of error2.validation) {
|
|
4461
|
+
const field = err.instancePath?.replace("/", "") || "body";
|
|
4462
|
+
if (!errors[field]) {
|
|
4463
|
+
errors[field] = [];
|
|
4464
|
+
}
|
|
4465
|
+
errors[field].push(err.message || "Invalid value");
|
|
4466
|
+
}
|
|
4467
|
+
return reply.status(400).send({
|
|
4468
|
+
success: false,
|
|
4469
|
+
message: "Validation failed",
|
|
4470
|
+
errors
|
|
4471
|
+
});
|
|
4472
|
+
}
|
|
4473
|
+
if ("statusCode" in error2 && typeof error2.statusCode === "number") {
|
|
4474
|
+
return reply.status(error2.statusCode).send({
|
|
4475
|
+
success: false,
|
|
4476
|
+
message: error2.message,
|
|
4477
|
+
...isProduction() ? {} : { stack: error2.stack }
|
|
4478
|
+
});
|
|
4479
|
+
}
|
|
4480
|
+
return reply.status(500).send({
|
|
4481
|
+
success: false,
|
|
4482
|
+
message: isProduction() ? "Internal server error" : error2.message,
|
|
4483
|
+
...isProduction() ? {} : { stack: error2.stack }
|
|
4484
|
+
});
|
|
4485
|
+
}
|
|
4486
|
+
);
|
|
4487
|
+
app.setNotFoundHandler((request, reply) => {
|
|
4488
|
+
return reply.status(404).send({
|
|
4489
|
+
success: false,
|
|
4490
|
+
message: `Route ${request.method} ${request.url} not found`
|
|
4491
|
+
});
|
|
4492
|
+
});
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4495
|
+
// src/middleware/security.ts
|
|
4496
|
+
import helmet from "@fastify/helmet";
|
|
4497
|
+
import cors from "@fastify/cors";
|
|
4498
|
+
import rateLimit from "@fastify/rate-limit";
|
|
4499
|
+
var defaultOptions = {
|
|
4500
|
+
helmet: true,
|
|
4501
|
+
cors: true,
|
|
4502
|
+
rateLimit: true
|
|
4503
|
+
};
|
|
4504
|
+
async function registerSecurity(app, options = {}) {
|
|
4505
|
+
const opts = { ...defaultOptions, ...options };
|
|
4506
|
+
if (opts.helmet) {
|
|
4507
|
+
await app.register(helmet, {
|
|
4508
|
+
contentSecurityPolicy: {
|
|
4509
|
+
directives: {
|
|
4510
|
+
defaultSrc: ["'self'"],
|
|
4511
|
+
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
4512
|
+
scriptSrc: ["'self'"],
|
|
4513
|
+
imgSrc: ["'self'", "data:", "https:"]
|
|
4514
|
+
}
|
|
4515
|
+
},
|
|
4516
|
+
crossOriginEmbedderPolicy: false
|
|
4517
|
+
});
|
|
4518
|
+
logger.debug("Helmet security headers enabled");
|
|
4519
|
+
}
|
|
4520
|
+
if (opts.cors) {
|
|
4521
|
+
await app.register(cors, {
|
|
4522
|
+
origin: config.security.corsOrigin,
|
|
4523
|
+
credentials: true,
|
|
4524
|
+
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
4525
|
+
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
|
|
4526
|
+
exposedHeaders: ["X-Total-Count", "X-Page", "X-Limit"],
|
|
4527
|
+
maxAge: 86400
|
|
4528
|
+
// 24 hours
|
|
4529
|
+
});
|
|
4530
|
+
logger.debug({ origin: config.security.corsOrigin }, "CORS enabled");
|
|
4531
|
+
}
|
|
4532
|
+
if (opts.rateLimit) {
|
|
4533
|
+
await app.register(rateLimit, {
|
|
4534
|
+
max: config.security.rateLimit.max,
|
|
4535
|
+
timeWindow: config.security.rateLimit.windowMs,
|
|
4536
|
+
errorResponseBuilder: (_request, context) => ({
|
|
4537
|
+
success: false,
|
|
4538
|
+
message: "Too many requests, please try again later",
|
|
4539
|
+
retryAfter: context.after
|
|
4540
|
+
}),
|
|
4541
|
+
keyGenerator: (request) => {
|
|
4542
|
+
return request.headers["x-forwarded-for"]?.toString().split(",")[0] || request.ip || "unknown";
|
|
4543
|
+
}
|
|
4544
|
+
});
|
|
4545
|
+
logger.debug(
|
|
4546
|
+
{
|
|
4547
|
+
max: config.security.rateLimit.max,
|
|
4548
|
+
windowMs: config.security.rateLimit.windowMs
|
|
4549
|
+
},
|
|
4550
|
+
"Rate limiting enabled"
|
|
4551
|
+
);
|
|
4552
|
+
}
|
|
4553
|
+
}
|
|
4554
|
+
|
|
4555
|
+
// src/modules/swagger/swagger.service.ts
|
|
4556
|
+
import swagger from "@fastify/swagger";
|
|
4557
|
+
import swaggerUi from "@fastify/swagger-ui";
|
|
4558
|
+
var defaultConfig3 = {
|
|
4559
|
+
title: "Servcraft API",
|
|
4560
|
+
description: "API documentation generated by Servcraft",
|
|
4561
|
+
version: "1.0.0",
|
|
4562
|
+
tags: [
|
|
4563
|
+
{ name: "Auth", description: "Authentication endpoints" },
|
|
4564
|
+
{ name: "Users", description: "User management endpoints" },
|
|
4565
|
+
{ name: "Health", description: "Health check endpoints" }
|
|
4566
|
+
]
|
|
4567
|
+
};
|
|
4568
|
+
async function registerSwagger(app, customConfig) {
|
|
4569
|
+
const swaggerConfig = { ...defaultConfig3, ...customConfig };
|
|
4570
|
+
await app.register(swagger, {
|
|
4571
|
+
openapi: {
|
|
4572
|
+
openapi: "3.0.3",
|
|
4573
|
+
info: {
|
|
4574
|
+
title: swaggerConfig.title,
|
|
4575
|
+
description: swaggerConfig.description,
|
|
4576
|
+
version: swaggerConfig.version,
|
|
4577
|
+
contact: swaggerConfig.contact,
|
|
4578
|
+
license: swaggerConfig.license
|
|
4579
|
+
},
|
|
4580
|
+
servers: swaggerConfig.servers || [
|
|
4581
|
+
{
|
|
4582
|
+
url: `http://localhost:${config.server.port}`,
|
|
4583
|
+
description: "Development server"
|
|
4584
|
+
}
|
|
4585
|
+
],
|
|
4586
|
+
tags: swaggerConfig.tags,
|
|
4587
|
+
components: {
|
|
4588
|
+
securitySchemes: {
|
|
4589
|
+
bearerAuth: {
|
|
4590
|
+
type: "http",
|
|
4591
|
+
scheme: "bearer",
|
|
4592
|
+
bearerFormat: "JWT",
|
|
4593
|
+
description: "Enter your JWT token"
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4596
|
+
}
|
|
4597
|
+
}
|
|
4598
|
+
});
|
|
4599
|
+
await app.register(swaggerUi, {
|
|
4600
|
+
routePrefix: "/docs",
|
|
4601
|
+
uiConfig: {
|
|
4602
|
+
docExpansion: "list",
|
|
4603
|
+
deepLinking: true,
|
|
4604
|
+
displayRequestDuration: true,
|
|
4605
|
+
filter: true,
|
|
4606
|
+
showExtensions: true,
|
|
4607
|
+
showCommonExtensions: true
|
|
4608
|
+
},
|
|
4609
|
+
staticCSP: true,
|
|
4610
|
+
transformStaticCSP: (header) => header
|
|
4611
|
+
});
|
|
4612
|
+
logger.info("Swagger documentation registered at /docs");
|
|
4613
|
+
}
|
|
4614
|
+
|
|
4615
|
+
// src/modules/auth/index.ts
|
|
4616
|
+
import jwt from "@fastify/jwt";
|
|
4617
|
+
import cookie from "@fastify/cookie";
|
|
4618
|
+
|
|
4619
|
+
// src/modules/auth/auth.service.ts
|
|
4620
|
+
import bcrypt from "bcryptjs";
|
|
4621
|
+
import { Redis } from "ioredis";
|
|
4622
|
+
var AuthService = class {
|
|
4623
|
+
app;
|
|
4624
|
+
SALT_ROUNDS = 12;
|
|
4625
|
+
redis = null;
|
|
4626
|
+
BLACKLIST_PREFIX = "auth:blacklist:";
|
|
4627
|
+
BLACKLIST_TTL = 7 * 24 * 60 * 60;
|
|
4628
|
+
// 7 days in seconds
|
|
4629
|
+
constructor(app, redisUrl) {
|
|
4630
|
+
this.app = app;
|
|
4631
|
+
if (redisUrl || process.env.REDIS_URL) {
|
|
4632
|
+
try {
|
|
4633
|
+
this.redis = new Redis(redisUrl || process.env.REDIS_URL || "redis://localhost:6379");
|
|
4634
|
+
this.redis.on("connect", () => {
|
|
4635
|
+
logger.info("Auth service connected to Redis for token blacklist");
|
|
4636
|
+
});
|
|
4637
|
+
this.redis.on("error", (error2) => {
|
|
4638
|
+
logger.error({ err: error2 }, "Redis connection error in Auth service");
|
|
4639
|
+
});
|
|
4640
|
+
} catch (error2) {
|
|
4641
|
+
logger.warn({ err: error2 }, "Failed to connect to Redis, using in-memory blacklist");
|
|
4642
|
+
this.redis = null;
|
|
4643
|
+
}
|
|
4644
|
+
} else {
|
|
4645
|
+
logger.warn(
|
|
4646
|
+
"No REDIS_URL provided, using in-memory token blacklist (not recommended for production)"
|
|
4647
|
+
);
|
|
4648
|
+
}
|
|
4649
|
+
}
|
|
4650
|
+
async hashPassword(password) {
|
|
4651
|
+
return bcrypt.hash(password, this.SALT_ROUNDS);
|
|
4652
|
+
}
|
|
4653
|
+
async verifyPassword(password, hash) {
|
|
4654
|
+
return bcrypt.compare(password, hash);
|
|
4655
|
+
}
|
|
4656
|
+
generateTokenPair(user) {
|
|
4657
|
+
const accessPayload = {
|
|
4658
|
+
sub: user.id,
|
|
4659
|
+
email: user.email,
|
|
4660
|
+
role: user.role,
|
|
4661
|
+
type: "access"
|
|
4662
|
+
};
|
|
4663
|
+
const refreshPayload = {
|
|
4664
|
+
sub: user.id,
|
|
4665
|
+
email: user.email,
|
|
4666
|
+
role: user.role,
|
|
4667
|
+
type: "refresh"
|
|
4668
|
+
};
|
|
4669
|
+
const accessToken = this.app.jwt.sign(accessPayload, {
|
|
4670
|
+
expiresIn: config.jwt.accessExpiresIn
|
|
4671
|
+
});
|
|
4672
|
+
const refreshToken = this.app.jwt.sign(refreshPayload, {
|
|
4673
|
+
expiresIn: config.jwt.refreshExpiresIn
|
|
4674
|
+
});
|
|
4675
|
+
const expiresIn = this.parseExpiration(config.jwt.accessExpiresIn);
|
|
4676
|
+
return { accessToken, refreshToken, expiresIn };
|
|
4677
|
+
}
|
|
4678
|
+
parseExpiration(expiration) {
|
|
4679
|
+
const match = expiration.match(/^(\d+)([smhd])$/);
|
|
4680
|
+
if (!match) return 900;
|
|
4681
|
+
const value = parseInt(match[1] || "0", 10);
|
|
4682
|
+
const unit = match[2];
|
|
4683
|
+
switch (unit) {
|
|
4684
|
+
case "s":
|
|
4685
|
+
return value;
|
|
4686
|
+
case "m":
|
|
4687
|
+
return value * 60;
|
|
4688
|
+
case "h":
|
|
4689
|
+
return value * 3600;
|
|
4690
|
+
case "d":
|
|
4691
|
+
return value * 86400;
|
|
4692
|
+
default:
|
|
4693
|
+
return 900;
|
|
4694
|
+
}
|
|
4695
|
+
}
|
|
4696
|
+
async verifyAccessToken(token) {
|
|
4697
|
+
try {
|
|
4698
|
+
if (await this.isTokenBlacklisted(token)) {
|
|
4699
|
+
throw new UnauthorizedError("Token has been revoked");
|
|
4700
|
+
}
|
|
4701
|
+
const payload = this.app.jwt.verify(token);
|
|
4702
|
+
if (payload.type !== "access") {
|
|
4703
|
+
throw new UnauthorizedError("Invalid token type");
|
|
4704
|
+
}
|
|
4705
|
+
return payload;
|
|
4706
|
+
} catch (error2) {
|
|
4707
|
+
if (error2 instanceof UnauthorizedError) throw error2;
|
|
4708
|
+
logger.debug({ err: error2 }, "Token verification failed");
|
|
4709
|
+
throw new UnauthorizedError("Invalid or expired token");
|
|
4710
|
+
}
|
|
4711
|
+
}
|
|
4712
|
+
async verifyRefreshToken(token) {
|
|
4713
|
+
try {
|
|
4714
|
+
if (await this.isTokenBlacklisted(token)) {
|
|
4715
|
+
throw new UnauthorizedError("Token has been revoked");
|
|
4716
|
+
}
|
|
4717
|
+
const payload = this.app.jwt.verify(token);
|
|
4718
|
+
if (payload.type !== "refresh") {
|
|
4719
|
+
throw new UnauthorizedError("Invalid token type");
|
|
4720
|
+
}
|
|
4721
|
+
return payload;
|
|
4722
|
+
} catch (error2) {
|
|
4723
|
+
if (error2 instanceof UnauthorizedError) throw error2;
|
|
4724
|
+
logger.debug({ err: error2 }, "Refresh token verification failed");
|
|
4725
|
+
throw new UnauthorizedError("Invalid or expired refresh token");
|
|
4726
|
+
}
|
|
4727
|
+
}
|
|
4728
|
+
/**
|
|
4729
|
+
* Blacklist a token (JWT revocation)
|
|
4730
|
+
* Uses Redis if available, falls back to in-memory Set
|
|
4731
|
+
*/
|
|
4732
|
+
async blacklistToken(token) {
|
|
4733
|
+
if (this.redis) {
|
|
4734
|
+
try {
|
|
4735
|
+
const key = `${this.BLACKLIST_PREFIX}${token}`;
|
|
4736
|
+
await this.redis.setex(key, this.BLACKLIST_TTL, "1");
|
|
4737
|
+
logger.debug("Token blacklisted in Redis");
|
|
4738
|
+
} catch (error2) {
|
|
4739
|
+
logger.error({ err: error2 }, "Failed to blacklist token in Redis");
|
|
4740
|
+
throw new Error("Failed to revoke token");
|
|
4741
|
+
}
|
|
4742
|
+
} else {
|
|
4743
|
+
logger.warn("Using in-memory blacklist - not suitable for multi-instance deployments");
|
|
4744
|
+
}
|
|
4745
|
+
}
|
|
4746
|
+
/**
|
|
4747
|
+
* Check if a token is blacklisted
|
|
4748
|
+
* Uses Redis if available, falls back to always returning false
|
|
4749
|
+
*/
|
|
4750
|
+
async isTokenBlacklisted(token) {
|
|
4751
|
+
if (this.redis) {
|
|
4752
|
+
try {
|
|
4753
|
+
const key = `${this.BLACKLIST_PREFIX}${token}`;
|
|
4754
|
+
const result = await this.redis.exists(key);
|
|
4755
|
+
return result === 1;
|
|
4756
|
+
} catch (error2) {
|
|
4757
|
+
logger.error({ err: error2 }, "Failed to check token blacklist in Redis");
|
|
4758
|
+
return false;
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
return false;
|
|
4762
|
+
}
|
|
4763
|
+
/**
|
|
4764
|
+
* Get count of blacklisted tokens (Redis only)
|
|
4765
|
+
*/
|
|
4766
|
+
async getBlacklistCount() {
|
|
4767
|
+
if (this.redis) {
|
|
4768
|
+
try {
|
|
4769
|
+
const keys = await this.redis.keys(`${this.BLACKLIST_PREFIX}*`);
|
|
4770
|
+
return keys.length;
|
|
4771
|
+
} catch (error2) {
|
|
4772
|
+
logger.error({ err: error2 }, "Failed to get blacklist count from Redis");
|
|
4773
|
+
return 0;
|
|
4774
|
+
}
|
|
4775
|
+
}
|
|
4776
|
+
return 0;
|
|
4777
|
+
}
|
|
4778
|
+
/**
|
|
4779
|
+
* Close Redis connection
|
|
4780
|
+
*/
|
|
4781
|
+
async close() {
|
|
4782
|
+
if (this.redis) {
|
|
4783
|
+
await this.redis.quit();
|
|
4784
|
+
logger.info("Auth service Redis connection closed");
|
|
4785
|
+
}
|
|
4786
|
+
}
|
|
4787
|
+
// OAuth support methods - to be implemented with user repository
|
|
4788
|
+
async findUserByEmail(_email) {
|
|
4789
|
+
return null;
|
|
4790
|
+
}
|
|
4791
|
+
async createUserFromOAuth(data) {
|
|
4792
|
+
const user = {
|
|
4793
|
+
id: `oauth_${Date.now()}`,
|
|
4794
|
+
email: data.email,
|
|
4795
|
+
role: "user"
|
|
4796
|
+
};
|
|
4797
|
+
logger.info({ email: data.email }, "Created user from OAuth");
|
|
4798
|
+
return user;
|
|
4799
|
+
}
|
|
4800
|
+
async generateTokensForUser(userId) {
|
|
4801
|
+
const user = {
|
|
4802
|
+
id: userId,
|
|
4803
|
+
email: "",
|
|
4804
|
+
// Would be fetched from database in production
|
|
4805
|
+
role: "user"
|
|
4806
|
+
};
|
|
4807
|
+
return this.generateTokenPair(user);
|
|
4808
|
+
}
|
|
4809
|
+
async verifyPasswordById(userId, _password) {
|
|
4810
|
+
logger.debug({ userId }, "Password verification requested");
|
|
4811
|
+
return false;
|
|
4812
|
+
}
|
|
4813
|
+
};
|
|
4814
|
+
function createAuthService(app) {
|
|
4815
|
+
return new AuthService(app);
|
|
4816
|
+
}
|
|
4817
|
+
|
|
4818
|
+
// src/modules/auth/schemas.ts
|
|
4819
|
+
import { z as z2 } from "zod";
|
|
4820
|
+
var loginSchema = z2.object({
|
|
4821
|
+
email: z2.string().email("Invalid email address"),
|
|
4822
|
+
password: z2.string().min(1, "Password is required")
|
|
4823
|
+
});
|
|
4824
|
+
var registerSchema = z2.object({
|
|
4825
|
+
email: z2.string().email("Invalid email address"),
|
|
4826
|
+
password: z2.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number"),
|
|
4827
|
+
name: z2.string().min(2, "Name must be at least 2 characters").optional()
|
|
4828
|
+
});
|
|
4829
|
+
var refreshTokenSchema = z2.object({
|
|
4830
|
+
refreshToken: z2.string().min(1, "Refresh token is required")
|
|
4831
|
+
});
|
|
4832
|
+
var passwordResetRequestSchema = z2.object({
|
|
4833
|
+
email: z2.string().email("Invalid email address")
|
|
4834
|
+
});
|
|
4835
|
+
var passwordResetConfirmSchema = z2.object({
|
|
4836
|
+
token: z2.string().min(1, "Token is required"),
|
|
4837
|
+
password: z2.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number")
|
|
4838
|
+
});
|
|
4839
|
+
var changePasswordSchema = z2.object({
|
|
4840
|
+
currentPassword: z2.string().min(1, "Current password is required"),
|
|
4841
|
+
newPassword: z2.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number")
|
|
4842
|
+
});
|
|
4843
|
+
|
|
4844
|
+
// src/utils/response.ts
|
|
4845
|
+
function success2(reply, data, statusCode = 200) {
|
|
4846
|
+
const response = {
|
|
4847
|
+
success: true,
|
|
4848
|
+
data
|
|
4849
|
+
};
|
|
4850
|
+
return reply.status(statusCode).send(response);
|
|
4851
|
+
}
|
|
4852
|
+
function created(reply, data) {
|
|
4853
|
+
return success2(reply, data, 201);
|
|
4854
|
+
}
|
|
4855
|
+
function noContent(reply) {
|
|
4856
|
+
return reply.status(204).send();
|
|
4857
|
+
}
|
|
4858
|
+
|
|
4859
|
+
// src/modules/validation/validator.ts
|
|
4860
|
+
import { z as z3 } from "zod";
|
|
4861
|
+
function validateBody(schema, data) {
|
|
4862
|
+
const result = schema.safeParse(data);
|
|
4863
|
+
if (!result.success) {
|
|
4864
|
+
throw new ValidationError(formatZodErrors(result.error));
|
|
4865
|
+
}
|
|
4866
|
+
return result.data;
|
|
4867
|
+
}
|
|
4868
|
+
function validateQuery(schema, data) {
|
|
4869
|
+
const result = schema.safeParse(data);
|
|
4870
|
+
if (!result.success) {
|
|
4871
|
+
throw new ValidationError(formatZodErrors(result.error));
|
|
4872
|
+
}
|
|
4873
|
+
return result.data;
|
|
4874
|
+
}
|
|
4875
|
+
function formatZodErrors(error2) {
|
|
4876
|
+
const errors = {};
|
|
4877
|
+
for (const issue of error2.issues) {
|
|
4878
|
+
const path9 = issue.path.join(".") || "root";
|
|
4879
|
+
if (!errors[path9]) {
|
|
4880
|
+
errors[path9] = [];
|
|
4881
|
+
}
|
|
4882
|
+
errors[path9].push(issue.message);
|
|
4883
|
+
}
|
|
4884
|
+
return errors;
|
|
4885
|
+
}
|
|
4886
|
+
var idParamSchema = z3.object({
|
|
4887
|
+
id: z3.string().uuid("Invalid ID format")
|
|
4888
|
+
});
|
|
4889
|
+
var paginationSchema = z3.object({
|
|
4890
|
+
page: z3.string().transform(Number).optional().default("1"),
|
|
4891
|
+
limit: z3.string().transform(Number).optional().default("20"),
|
|
4892
|
+
sortBy: z3.string().optional(),
|
|
4893
|
+
sortOrder: z3.enum(["asc", "desc"]).optional().default("asc")
|
|
4894
|
+
});
|
|
4895
|
+
var searchSchema = z3.object({
|
|
4896
|
+
q: z3.string().min(1, "Search query is required").optional(),
|
|
4897
|
+
search: z3.string().min(1).optional()
|
|
4898
|
+
});
|
|
4899
|
+
var emailSchema = z3.string().email("Invalid email address");
|
|
4900
|
+
var passwordSchema = z3.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number").regex(/[^A-Za-z0-9]/, "Password must contain at least one special character");
|
|
4901
|
+
var urlSchema = z3.string().url("Invalid URL format");
|
|
4902
|
+
var phoneSchema = z3.string().regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number format");
|
|
4903
|
+
var dateSchema = z3.coerce.date();
|
|
4904
|
+
var futureDateSchema = z3.coerce.date().refine((date) => date > /* @__PURE__ */ new Date(), "Date must be in the future");
|
|
4905
|
+
var pastDateSchema = z3.coerce.date().refine((date) => date < /* @__PURE__ */ new Date(), "Date must be in the past");
|
|
4906
|
+
|
|
4907
|
+
// src/modules/auth/auth.controller.ts
|
|
4908
|
+
var AuthController = class {
|
|
4909
|
+
constructor(authService, userService) {
|
|
4910
|
+
this.authService = authService;
|
|
4911
|
+
this.userService = userService;
|
|
4912
|
+
}
|
|
4913
|
+
async register(request, reply) {
|
|
4914
|
+
const data = validateBody(registerSchema, request.body);
|
|
4915
|
+
const existingUser = await this.userService.findByEmail(data.email);
|
|
4916
|
+
if (existingUser) {
|
|
4917
|
+
throw new BadRequestError("Email already registered");
|
|
4918
|
+
}
|
|
4919
|
+
const hashedPassword = await this.authService.hashPassword(data.password);
|
|
4920
|
+
const user = await this.userService.create({
|
|
4921
|
+
email: data.email,
|
|
4922
|
+
password: hashedPassword,
|
|
4923
|
+
name: data.name
|
|
4924
|
+
});
|
|
4925
|
+
const tokens = this.authService.generateTokenPair({
|
|
4926
|
+
id: user.id,
|
|
4927
|
+
email: user.email,
|
|
4928
|
+
role: user.role
|
|
4929
|
+
});
|
|
4930
|
+
created(reply, {
|
|
4931
|
+
user: {
|
|
4932
|
+
id: user.id,
|
|
4933
|
+
email: user.email,
|
|
4934
|
+
name: user.name,
|
|
4935
|
+
role: user.role
|
|
4936
|
+
},
|
|
4937
|
+
...tokens
|
|
4938
|
+
});
|
|
4939
|
+
}
|
|
4940
|
+
async login(request, reply) {
|
|
4941
|
+
const data = validateBody(loginSchema, request.body);
|
|
4942
|
+
const user = await this.userService.findByEmail(data.email);
|
|
4943
|
+
if (!user) {
|
|
4944
|
+
throw new UnauthorizedError("Invalid credentials");
|
|
4945
|
+
}
|
|
4946
|
+
if (user.status !== "active") {
|
|
4947
|
+
throw new UnauthorizedError("Account is not active");
|
|
4948
|
+
}
|
|
4949
|
+
const isValidPassword = await this.authService.verifyPassword(data.password, user.password);
|
|
4950
|
+
if (!isValidPassword) {
|
|
4951
|
+
throw new UnauthorizedError("Invalid credentials");
|
|
4952
|
+
}
|
|
4953
|
+
await this.userService.updateLastLogin(user.id);
|
|
4954
|
+
const tokens = this.authService.generateTokenPair({
|
|
4955
|
+
id: user.id,
|
|
4956
|
+
email: user.email,
|
|
4957
|
+
role: user.role
|
|
4958
|
+
});
|
|
4959
|
+
success2(reply, {
|
|
4960
|
+
user: {
|
|
4961
|
+
id: user.id,
|
|
4962
|
+
email: user.email,
|
|
4963
|
+
name: user.name,
|
|
4964
|
+
role: user.role
|
|
4965
|
+
},
|
|
4966
|
+
...tokens
|
|
4967
|
+
});
|
|
4968
|
+
}
|
|
4969
|
+
async refresh(request, reply) {
|
|
4970
|
+
const data = validateBody(refreshTokenSchema, request.body);
|
|
4971
|
+
const payload = await this.authService.verifyRefreshToken(data.refreshToken);
|
|
4972
|
+
const user = await this.userService.findById(payload.sub);
|
|
4973
|
+
if (!user || user.status !== "active") {
|
|
4974
|
+
throw new UnauthorizedError("User not found or inactive");
|
|
4975
|
+
}
|
|
4976
|
+
await this.authService.blacklistToken(data.refreshToken);
|
|
4977
|
+
const tokens = this.authService.generateTokenPair({
|
|
4978
|
+
id: user.id,
|
|
4979
|
+
email: user.email,
|
|
4980
|
+
role: user.role
|
|
4981
|
+
});
|
|
4982
|
+
success2(reply, tokens);
|
|
4983
|
+
}
|
|
4984
|
+
async logout(request, reply) {
|
|
4985
|
+
const authHeader = request.headers.authorization;
|
|
4986
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
4987
|
+
const token = authHeader.substring(7);
|
|
4988
|
+
await this.authService.blacklistToken(token);
|
|
4989
|
+
}
|
|
4990
|
+
success2(reply, { message: "Logged out successfully" });
|
|
4991
|
+
}
|
|
4992
|
+
async me(request, reply) {
|
|
4993
|
+
const authRequest = request;
|
|
4994
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
4995
|
+
if (!user) {
|
|
4996
|
+
throw new UnauthorizedError("User not found");
|
|
4997
|
+
}
|
|
4998
|
+
success2(reply, {
|
|
4999
|
+
id: user.id,
|
|
5000
|
+
email: user.email,
|
|
5001
|
+
name: user.name,
|
|
5002
|
+
role: user.role,
|
|
5003
|
+
status: user.status,
|
|
5004
|
+
createdAt: user.createdAt
|
|
5005
|
+
});
|
|
5006
|
+
}
|
|
5007
|
+
async changePassword(request, reply) {
|
|
5008
|
+
const authRequest = request;
|
|
5009
|
+
const data = validateBody(changePasswordSchema, request.body);
|
|
5010
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
5011
|
+
if (!user) {
|
|
5012
|
+
throw new UnauthorizedError("User not found");
|
|
5013
|
+
}
|
|
5014
|
+
const isValidPassword = await this.authService.verifyPassword(
|
|
5015
|
+
data.currentPassword,
|
|
5016
|
+
user.password
|
|
5017
|
+
);
|
|
5018
|
+
if (!isValidPassword) {
|
|
5019
|
+
throw new BadRequestError("Current password is incorrect");
|
|
5020
|
+
}
|
|
5021
|
+
const hashedPassword = await this.authService.hashPassword(data.newPassword);
|
|
5022
|
+
await this.userService.updatePassword(user.id, hashedPassword);
|
|
5023
|
+
success2(reply, { message: "Password changed successfully" });
|
|
5024
|
+
}
|
|
5025
|
+
};
|
|
5026
|
+
function createAuthController(authService, userService) {
|
|
5027
|
+
return new AuthController(authService, userService);
|
|
5028
|
+
}
|
|
5029
|
+
|
|
5030
|
+
// src/modules/auth/auth.middleware.ts
|
|
5031
|
+
function createAuthMiddleware(authService) {
|
|
5032
|
+
return async function authenticate(request, _reply) {
|
|
5033
|
+
const authHeader = request.headers.authorization;
|
|
5034
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
5035
|
+
throw new UnauthorizedError("Missing or invalid authorization header");
|
|
5036
|
+
}
|
|
5037
|
+
const token = authHeader.substring(7);
|
|
5038
|
+
const payload = await authService.verifyAccessToken(token);
|
|
5039
|
+
request.user = {
|
|
5040
|
+
id: payload.sub,
|
|
5041
|
+
email: payload.email,
|
|
5042
|
+
role: payload.role
|
|
5043
|
+
};
|
|
5044
|
+
};
|
|
5045
|
+
}
|
|
5046
|
+
function createRoleMiddleware(allowedRoles) {
|
|
5047
|
+
return async function authorize(request, _reply) {
|
|
5048
|
+
const user = request.user;
|
|
5049
|
+
if (!user) {
|
|
5050
|
+
throw new UnauthorizedError("Authentication required");
|
|
5051
|
+
}
|
|
5052
|
+
if (!allowedRoles.includes(user.role)) {
|
|
5053
|
+
throw new ForbiddenError("Insufficient permissions");
|
|
5054
|
+
}
|
|
5055
|
+
};
|
|
5056
|
+
}
|
|
5057
|
+
|
|
5058
|
+
// src/modules/auth/auth.routes.ts
|
|
5059
|
+
function registerAuthRoutes(app, controller, authService) {
|
|
5060
|
+
const authenticate = createAuthMiddleware(authService);
|
|
5061
|
+
app.post("/auth/register", controller.register.bind(controller));
|
|
5062
|
+
app.post("/auth/login", controller.login.bind(controller));
|
|
5063
|
+
app.post("/auth/refresh", controller.refresh.bind(controller));
|
|
5064
|
+
app.post("/auth/logout", { preHandler: [authenticate] }, controller.logout.bind(controller));
|
|
5065
|
+
app.get("/auth/me", { preHandler: [authenticate] }, controller.me.bind(controller));
|
|
5066
|
+
app.post(
|
|
5067
|
+
"/auth/change-password",
|
|
5068
|
+
{ preHandler: [authenticate] },
|
|
5069
|
+
controller.changePassword.bind(controller)
|
|
5070
|
+
);
|
|
5071
|
+
}
|
|
5072
|
+
|
|
5073
|
+
// src/database/prisma.ts
|
|
5074
|
+
import { PrismaClient } from "@prisma/client";
|
|
5075
|
+
var prismaClientSingleton = () => {
|
|
5076
|
+
return new PrismaClient({
|
|
5077
|
+
log: isProduction() ? ["error"] : ["query", "info", "warn", "error"],
|
|
5078
|
+
errorFormat: isProduction() ? "minimal" : "pretty"
|
|
5079
|
+
});
|
|
5080
|
+
};
|
|
5081
|
+
var prisma = globalThis.__prisma ?? prismaClientSingleton();
|
|
5082
|
+
if (!isProduction()) {
|
|
5083
|
+
globalThis.__prisma = prisma;
|
|
5084
|
+
}
|
|
5085
|
+
|
|
5086
|
+
// src/utils/pagination.ts
|
|
5087
|
+
var DEFAULT_PAGE = 1;
|
|
5088
|
+
var DEFAULT_LIMIT = 20;
|
|
5089
|
+
var MAX_LIMIT = 100;
|
|
5090
|
+
function parsePaginationParams(query) {
|
|
5091
|
+
const page = Math.max(1, parseInt(String(query.page || DEFAULT_PAGE), 10));
|
|
5092
|
+
const limit = Math.min(
|
|
5093
|
+
MAX_LIMIT,
|
|
5094
|
+
Math.max(1, parseInt(String(query.limit || DEFAULT_LIMIT), 10))
|
|
5095
|
+
);
|
|
5096
|
+
const sortBy = typeof query.sortBy === "string" ? query.sortBy : void 0;
|
|
5097
|
+
const sortOrder = query.sortOrder === "desc" ? "desc" : "asc";
|
|
5098
|
+
return { page, limit, sortBy, sortOrder };
|
|
5099
|
+
}
|
|
5100
|
+
function createPaginatedResult(data, total, params) {
|
|
5101
|
+
const totalPages = Math.ceil(total / params.limit);
|
|
5102
|
+
return {
|
|
5103
|
+
data,
|
|
5104
|
+
meta: {
|
|
5105
|
+
total,
|
|
5106
|
+
page: params.page,
|
|
5107
|
+
limit: params.limit,
|
|
5108
|
+
totalPages,
|
|
5109
|
+
hasNextPage: params.page < totalPages,
|
|
5110
|
+
hasPrevPage: params.page > 1
|
|
5111
|
+
}
|
|
5112
|
+
};
|
|
5113
|
+
}
|
|
5114
|
+
function getSkip(params) {
|
|
5115
|
+
return (params.page - 1) * params.limit;
|
|
5116
|
+
}
|
|
5117
|
+
|
|
5118
|
+
// src/modules/user/user.repository.ts
|
|
5119
|
+
import { UserRole, UserStatus } from "@prisma/client";
|
|
5120
|
+
var UserRepository = class {
|
|
5121
|
+
/**
|
|
5122
|
+
* Find user by ID
|
|
5123
|
+
*/
|
|
5124
|
+
async findById(id) {
|
|
5125
|
+
const user = await prisma.user.findUnique({
|
|
5126
|
+
where: { id }
|
|
5127
|
+
});
|
|
5128
|
+
if (!user) return null;
|
|
5129
|
+
return this.mapPrismaUserToUser(user);
|
|
5130
|
+
}
|
|
5131
|
+
/**
|
|
5132
|
+
* Find user by email (case-insensitive)
|
|
5133
|
+
*/
|
|
5134
|
+
async findByEmail(email) {
|
|
5135
|
+
const user = await prisma.user.findUnique({
|
|
5136
|
+
where: { email: email.toLowerCase() }
|
|
5137
|
+
});
|
|
5138
|
+
if (!user) return null;
|
|
5139
|
+
return this.mapPrismaUserToUser(user);
|
|
5140
|
+
}
|
|
5141
|
+
/**
|
|
5142
|
+
* Find multiple users with pagination and filters
|
|
5143
|
+
*/
|
|
5144
|
+
async findMany(params, filters) {
|
|
5145
|
+
const where = this.buildWhereClause(filters);
|
|
5146
|
+
const orderBy = this.buildOrderBy(params);
|
|
5147
|
+
const [data, total] = await Promise.all([
|
|
5148
|
+
prisma.user.findMany({
|
|
5149
|
+
where,
|
|
5150
|
+
orderBy,
|
|
5151
|
+
skip: getSkip(params),
|
|
5152
|
+
take: params.limit
|
|
5153
|
+
}),
|
|
5154
|
+
prisma.user.count({ where })
|
|
5155
|
+
]);
|
|
5156
|
+
const mappedUsers = data.map((user) => this.mapPrismaUserToUser(user));
|
|
5157
|
+
return createPaginatedResult(mappedUsers, total, params);
|
|
5158
|
+
}
|
|
5159
|
+
/**
|
|
5160
|
+
* Create a new user
|
|
5161
|
+
*/
|
|
5162
|
+
async create(data) {
|
|
5163
|
+
const user = await prisma.user.create({
|
|
5164
|
+
data: {
|
|
5165
|
+
email: data.email.toLowerCase(),
|
|
5166
|
+
password: data.password,
|
|
5167
|
+
name: data.name,
|
|
5168
|
+
role: this.mapRoleToEnum(data.role || "user"),
|
|
5169
|
+
status: UserStatus.ACTIVE,
|
|
5170
|
+
emailVerified: false
|
|
5171
|
+
}
|
|
5172
|
+
});
|
|
5173
|
+
return this.mapPrismaUserToUser(user);
|
|
5174
|
+
}
|
|
5175
|
+
/**
|
|
5176
|
+
* Update user data
|
|
5177
|
+
*/
|
|
5178
|
+
async update(id, data) {
|
|
5179
|
+
try {
|
|
5180
|
+
const user = await prisma.user.update({
|
|
5181
|
+
where: { id },
|
|
5182
|
+
data: {
|
|
5183
|
+
...data.email && { email: data.email.toLowerCase() },
|
|
5184
|
+
...data.name !== void 0 && { name: data.name },
|
|
5185
|
+
...data.role && { role: this.mapRoleToEnum(data.role) },
|
|
5186
|
+
...data.status && { status: this.mapStatusToEnum(data.status) },
|
|
5187
|
+
...data.emailVerified !== void 0 && { emailVerified: data.emailVerified },
|
|
5188
|
+
...data.metadata && { metadata: data.metadata }
|
|
5189
|
+
}
|
|
5190
|
+
});
|
|
5191
|
+
return this.mapPrismaUserToUser(user);
|
|
5192
|
+
} catch {
|
|
5193
|
+
return null;
|
|
5194
|
+
}
|
|
5195
|
+
}
|
|
5196
|
+
/**
|
|
5197
|
+
* Update user password
|
|
5198
|
+
*/
|
|
5199
|
+
async updatePassword(id, password) {
|
|
5200
|
+
try {
|
|
5201
|
+
const user = await prisma.user.update({
|
|
5202
|
+
where: { id },
|
|
5203
|
+
data: { password }
|
|
5204
|
+
});
|
|
5205
|
+
return this.mapPrismaUserToUser(user);
|
|
5206
|
+
} catch {
|
|
5207
|
+
return null;
|
|
5208
|
+
}
|
|
5209
|
+
}
|
|
5210
|
+
/**
|
|
5211
|
+
* Update last login timestamp
|
|
5212
|
+
*/
|
|
5213
|
+
async updateLastLogin(id) {
|
|
5214
|
+
try {
|
|
5215
|
+
const user = await prisma.user.update({
|
|
5216
|
+
where: { id },
|
|
5217
|
+
data: { lastLoginAt: /* @__PURE__ */ new Date() }
|
|
5218
|
+
});
|
|
5219
|
+
return this.mapPrismaUserToUser(user);
|
|
5220
|
+
} catch {
|
|
5221
|
+
return null;
|
|
5222
|
+
}
|
|
5223
|
+
}
|
|
5224
|
+
/**
|
|
5225
|
+
* Delete user by ID
|
|
5226
|
+
*/
|
|
5227
|
+
async delete(id) {
|
|
5228
|
+
try {
|
|
5229
|
+
await prisma.user.delete({
|
|
5230
|
+
where: { id }
|
|
5231
|
+
});
|
|
5232
|
+
return true;
|
|
5233
|
+
} catch {
|
|
5234
|
+
return false;
|
|
5235
|
+
}
|
|
5236
|
+
}
|
|
5237
|
+
/**
|
|
5238
|
+
* Count users with optional filters
|
|
5239
|
+
*/
|
|
5240
|
+
async count(filters) {
|
|
5241
|
+
const where = this.buildWhereClause(filters);
|
|
5242
|
+
return prisma.user.count({ where });
|
|
5243
|
+
}
|
|
5244
|
+
/**
|
|
5245
|
+
* Helper to clear all users (for testing only)
|
|
5246
|
+
* WARNING: This deletes all users from the database
|
|
5247
|
+
*/
|
|
5248
|
+
async clear() {
|
|
5249
|
+
await prisma.user.deleteMany();
|
|
5250
|
+
}
|
|
5251
|
+
/**
|
|
5252
|
+
* Build Prisma where clause from filters
|
|
5253
|
+
*/
|
|
5254
|
+
buildWhereClause(filters) {
|
|
5255
|
+
if (!filters) return {};
|
|
5256
|
+
return {
|
|
5257
|
+
...filters.status && { status: this.mapStatusToEnum(filters.status) },
|
|
5258
|
+
...filters.role && { role: this.mapRoleToEnum(filters.role) },
|
|
5259
|
+
...filters.emailVerified !== void 0 && { emailVerified: filters.emailVerified },
|
|
5260
|
+
...filters.search && {
|
|
5261
|
+
OR: [
|
|
5262
|
+
{ email: { contains: filters.search, mode: "insensitive" } },
|
|
5263
|
+
{ name: { contains: filters.search, mode: "insensitive" } }
|
|
5264
|
+
]
|
|
5265
|
+
}
|
|
5266
|
+
};
|
|
5267
|
+
}
|
|
5268
|
+
/**
|
|
5269
|
+
* Build Prisma orderBy clause from pagination params
|
|
5270
|
+
*/
|
|
5271
|
+
buildOrderBy(params) {
|
|
5272
|
+
if (!params.sortBy) {
|
|
5273
|
+
return { createdAt: "desc" };
|
|
5274
|
+
}
|
|
5275
|
+
return {
|
|
5276
|
+
[params.sortBy]: params.sortOrder || "asc"
|
|
5277
|
+
};
|
|
5278
|
+
}
|
|
5279
|
+
/**
|
|
5280
|
+
* Map Prisma User to application User type
|
|
5281
|
+
*/
|
|
5282
|
+
mapPrismaUserToUser(prismaUser) {
|
|
5283
|
+
return {
|
|
5284
|
+
id: prismaUser.id,
|
|
5285
|
+
email: prismaUser.email,
|
|
5286
|
+
password: prismaUser.password,
|
|
5287
|
+
name: prismaUser.name ?? void 0,
|
|
5288
|
+
role: this.mapEnumToRole(prismaUser.role),
|
|
5289
|
+
status: this.mapEnumToStatus(prismaUser.status),
|
|
5290
|
+
emailVerified: prismaUser.emailVerified,
|
|
5291
|
+
lastLoginAt: prismaUser.lastLoginAt ?? void 0,
|
|
5292
|
+
metadata: prismaUser.metadata,
|
|
5293
|
+
createdAt: prismaUser.createdAt,
|
|
5294
|
+
updatedAt: prismaUser.updatedAt
|
|
5295
|
+
};
|
|
5296
|
+
}
|
|
5297
|
+
/**
|
|
5298
|
+
* Map application role to Prisma enum
|
|
5299
|
+
*/
|
|
5300
|
+
mapRoleToEnum(role) {
|
|
5301
|
+
const roleMap = {
|
|
5302
|
+
user: UserRole.USER,
|
|
5303
|
+
moderator: UserRole.MODERATOR,
|
|
5304
|
+
admin: UserRole.ADMIN,
|
|
5305
|
+
super_admin: UserRole.SUPER_ADMIN
|
|
5306
|
+
};
|
|
5307
|
+
return roleMap[role] || UserRole.USER;
|
|
5308
|
+
}
|
|
5309
|
+
/**
|
|
5310
|
+
* Map Prisma enum to application role
|
|
5311
|
+
*/
|
|
5312
|
+
mapEnumToRole(role) {
|
|
5313
|
+
const roleMap = {
|
|
5314
|
+
[UserRole.USER]: "user",
|
|
5315
|
+
[UserRole.MODERATOR]: "moderator",
|
|
5316
|
+
[UserRole.ADMIN]: "admin",
|
|
5317
|
+
[UserRole.SUPER_ADMIN]: "super_admin"
|
|
5318
|
+
};
|
|
5319
|
+
return roleMap[role];
|
|
5320
|
+
}
|
|
5321
|
+
/**
|
|
5322
|
+
* Map application status to Prisma enum
|
|
5323
|
+
*/
|
|
5324
|
+
mapStatusToEnum(status) {
|
|
5325
|
+
const statusMap = {
|
|
5326
|
+
active: UserStatus.ACTIVE,
|
|
5327
|
+
inactive: UserStatus.INACTIVE,
|
|
5328
|
+
suspended: UserStatus.SUSPENDED,
|
|
5329
|
+
banned: UserStatus.BANNED
|
|
5330
|
+
};
|
|
5331
|
+
return statusMap[status] || UserStatus.ACTIVE;
|
|
5332
|
+
}
|
|
5333
|
+
/**
|
|
5334
|
+
* Map Prisma enum to application status
|
|
5335
|
+
*/
|
|
5336
|
+
mapEnumToStatus(status) {
|
|
5337
|
+
const statusMap = {
|
|
5338
|
+
[UserStatus.ACTIVE]: "active",
|
|
5339
|
+
[UserStatus.INACTIVE]: "inactive",
|
|
5340
|
+
[UserStatus.SUSPENDED]: "suspended",
|
|
5341
|
+
[UserStatus.BANNED]: "banned"
|
|
5342
|
+
};
|
|
5343
|
+
return statusMap[status];
|
|
5344
|
+
}
|
|
5345
|
+
};
|
|
5346
|
+
function createUserRepository() {
|
|
5347
|
+
return new UserRepository();
|
|
5348
|
+
}
|
|
5349
|
+
|
|
5350
|
+
// src/modules/user/types.ts
|
|
5351
|
+
var DEFAULT_ROLE_PERMISSIONS = {
|
|
5352
|
+
user: ["profile:read", "profile:update"],
|
|
5353
|
+
moderator: [
|
|
5354
|
+
"profile:read",
|
|
5355
|
+
"profile:update",
|
|
5356
|
+
"users:read",
|
|
5357
|
+
"content:read",
|
|
5358
|
+
"content:update",
|
|
5359
|
+
"content:delete"
|
|
5360
|
+
],
|
|
5361
|
+
admin: [
|
|
5362
|
+
"profile:read",
|
|
5363
|
+
"profile:update",
|
|
5364
|
+
"users:read",
|
|
5365
|
+
"users:update",
|
|
5366
|
+
"users:delete",
|
|
5367
|
+
"content:manage",
|
|
5368
|
+
"settings:read"
|
|
5369
|
+
],
|
|
5370
|
+
super_admin: ["*:manage"]
|
|
5371
|
+
// All permissions
|
|
5372
|
+
};
|
|
5373
|
+
|
|
5374
|
+
// src/modules/user/user.service.ts
|
|
5375
|
+
var UserService = class {
|
|
5376
|
+
constructor(repository) {
|
|
5377
|
+
this.repository = repository;
|
|
5378
|
+
}
|
|
5379
|
+
async findById(id) {
|
|
5380
|
+
return this.repository.findById(id);
|
|
5381
|
+
}
|
|
5382
|
+
async findByEmail(email) {
|
|
5383
|
+
return this.repository.findByEmail(email);
|
|
5384
|
+
}
|
|
5385
|
+
async findMany(params, filters) {
|
|
5386
|
+
const result = await this.repository.findMany(params, filters);
|
|
5387
|
+
return {
|
|
5388
|
+
...result,
|
|
5389
|
+
data: result.data.map(({ password: _password, ...user }) => user)
|
|
5390
|
+
};
|
|
5391
|
+
}
|
|
5392
|
+
async create(data) {
|
|
5393
|
+
const existing = await this.repository.findByEmail(data.email);
|
|
5394
|
+
if (existing) {
|
|
5395
|
+
throw new ConflictError("User with this email already exists");
|
|
5396
|
+
}
|
|
5397
|
+
const user = await this.repository.create(data);
|
|
5398
|
+
logger.info({ userId: user.id, email: user.email }, "User created");
|
|
5399
|
+
return user;
|
|
5400
|
+
}
|
|
5401
|
+
async update(id, data) {
|
|
5402
|
+
const user = await this.repository.findById(id);
|
|
5403
|
+
if (!user) {
|
|
5404
|
+
throw new NotFoundError("User");
|
|
5405
|
+
}
|
|
5406
|
+
if (data.email && data.email !== user.email) {
|
|
5407
|
+
const existing = await this.repository.findByEmail(data.email);
|
|
5408
|
+
if (existing) {
|
|
5409
|
+
throw new ConflictError("Email already in use");
|
|
5410
|
+
}
|
|
5411
|
+
}
|
|
5412
|
+
const updatedUser = await this.repository.update(id, data);
|
|
5413
|
+
if (!updatedUser) {
|
|
5414
|
+
throw new NotFoundError("User");
|
|
5415
|
+
}
|
|
5416
|
+
logger.info({ userId: id }, "User updated");
|
|
5417
|
+
return updatedUser;
|
|
5418
|
+
}
|
|
5419
|
+
async updatePassword(id, hashedPassword) {
|
|
5420
|
+
const user = await this.repository.updatePassword(id, hashedPassword);
|
|
5421
|
+
if (!user) {
|
|
5422
|
+
throw new NotFoundError("User");
|
|
5423
|
+
}
|
|
5424
|
+
logger.info({ userId: id }, "User password updated");
|
|
5425
|
+
return user;
|
|
5426
|
+
}
|
|
5427
|
+
async updateLastLogin(id) {
|
|
5428
|
+
const user = await this.repository.updateLastLogin(id);
|
|
5429
|
+
if (!user) {
|
|
5430
|
+
throw new NotFoundError("User");
|
|
5431
|
+
}
|
|
5432
|
+
return user;
|
|
5433
|
+
}
|
|
5434
|
+
async delete(id) {
|
|
5435
|
+
const user = await this.repository.findById(id);
|
|
5436
|
+
if (!user) {
|
|
5437
|
+
throw new NotFoundError("User");
|
|
5438
|
+
}
|
|
5439
|
+
await this.repository.delete(id);
|
|
5440
|
+
logger.info({ userId: id }, "User deleted");
|
|
5441
|
+
}
|
|
5442
|
+
async suspend(id) {
|
|
5443
|
+
return this.update(id, { status: "suspended" });
|
|
5444
|
+
}
|
|
5445
|
+
async ban(id) {
|
|
5446
|
+
return this.update(id, { status: "banned" });
|
|
5447
|
+
}
|
|
5448
|
+
async activate(id) {
|
|
5449
|
+
return this.update(id, { status: "active" });
|
|
5450
|
+
}
|
|
5451
|
+
async verifyEmail(id) {
|
|
5452
|
+
return this.update(id, { emailVerified: true });
|
|
5453
|
+
}
|
|
5454
|
+
async changeRole(id, role) {
|
|
5455
|
+
return this.update(id, { role });
|
|
5456
|
+
}
|
|
5457
|
+
// RBAC helpers
|
|
5458
|
+
hasPermission(role, permission) {
|
|
5459
|
+
const permissions = DEFAULT_ROLE_PERMISSIONS[role] || [];
|
|
5460
|
+
if (permissions.includes("*:manage")) {
|
|
5461
|
+
return true;
|
|
5462
|
+
}
|
|
5463
|
+
if (permissions.includes(permission)) {
|
|
5464
|
+
return true;
|
|
5465
|
+
}
|
|
5466
|
+
const [resource] = permission.split(":");
|
|
5467
|
+
const managePermission = `${resource}:manage`;
|
|
5468
|
+
if (permissions.includes(managePermission)) {
|
|
5469
|
+
return true;
|
|
5470
|
+
}
|
|
5471
|
+
return false;
|
|
5472
|
+
}
|
|
5473
|
+
getPermissions(role) {
|
|
5474
|
+
return DEFAULT_ROLE_PERMISSIONS[role] || [];
|
|
5475
|
+
}
|
|
5476
|
+
};
|
|
5477
|
+
function createUserService(repository) {
|
|
5478
|
+
return new UserService(repository || createUserRepository());
|
|
5479
|
+
}
|
|
5480
|
+
|
|
5481
|
+
// src/modules/auth/index.ts
|
|
5482
|
+
async function registerAuthModule(app) {
|
|
5483
|
+
await app.register(jwt, {
|
|
5484
|
+
secret: config.jwt.secret,
|
|
5485
|
+
sign: {
|
|
5486
|
+
algorithm: "HS256"
|
|
5487
|
+
}
|
|
5488
|
+
});
|
|
5489
|
+
await app.register(cookie, {
|
|
5490
|
+
secret: config.jwt.secret,
|
|
5491
|
+
hook: "onRequest"
|
|
5492
|
+
});
|
|
5493
|
+
const authService = createAuthService(app);
|
|
5494
|
+
const userService = createUserService();
|
|
5495
|
+
const authController = createAuthController(authService, userService);
|
|
5496
|
+
registerAuthRoutes(app, authController, authService);
|
|
5497
|
+
logger.info("Auth module registered");
|
|
5498
|
+
}
|
|
5499
|
+
|
|
5500
|
+
// src/modules/user/schemas.ts
|
|
5501
|
+
import { z as z4 } from "zod";
|
|
5502
|
+
var userStatusEnum = z4.enum(["active", "inactive", "suspended", "banned"]);
|
|
5503
|
+
var userRoleEnum = z4.enum(["user", "admin", "moderator", "super_admin"]);
|
|
5504
|
+
var createUserSchema = z4.object({
|
|
5505
|
+
email: z4.string().email("Invalid email address"),
|
|
5506
|
+
password: z4.string().min(8, "Password must be at least 8 characters").regex(/[A-Z]/, "Password must contain at least one uppercase letter").regex(/[a-z]/, "Password must contain at least one lowercase letter").regex(/[0-9]/, "Password must contain at least one number"),
|
|
5507
|
+
name: z4.string().min(2, "Name must be at least 2 characters").optional(),
|
|
5508
|
+
role: userRoleEnum.optional().default("user")
|
|
5509
|
+
});
|
|
5510
|
+
var updateUserSchema = z4.object({
|
|
5511
|
+
email: z4.string().email("Invalid email address").optional(),
|
|
5512
|
+
name: z4.string().min(2, "Name must be at least 2 characters").optional(),
|
|
5513
|
+
role: userRoleEnum.optional(),
|
|
5514
|
+
status: userStatusEnum.optional(),
|
|
5515
|
+
emailVerified: z4.boolean().optional(),
|
|
5516
|
+
metadata: z4.record(z4.unknown()).optional()
|
|
5517
|
+
});
|
|
5518
|
+
var updateProfileSchema = z4.object({
|
|
5519
|
+
name: z4.string().min(2, "Name must be at least 2 characters").optional(),
|
|
5520
|
+
metadata: z4.record(z4.unknown()).optional()
|
|
5521
|
+
});
|
|
5522
|
+
var userQuerySchema = z4.object({
|
|
5523
|
+
page: z4.string().transform(Number).optional(),
|
|
5524
|
+
limit: z4.string().transform(Number).optional(),
|
|
5525
|
+
sortBy: z4.string().optional(),
|
|
5526
|
+
sortOrder: z4.enum(["asc", "desc"]).optional(),
|
|
5527
|
+
status: userStatusEnum.optional(),
|
|
5528
|
+
role: userRoleEnum.optional(),
|
|
5529
|
+
search: z4.string().optional(),
|
|
5530
|
+
emailVerified: z4.string().transform((val) => val === "true").optional()
|
|
5531
|
+
});
|
|
5532
|
+
|
|
5533
|
+
// src/modules/user/user.controller.ts
|
|
5534
|
+
function omitPassword(user) {
|
|
5535
|
+
const { password, ...userData } = user;
|
|
5536
|
+
void password;
|
|
5537
|
+
return userData;
|
|
5538
|
+
}
|
|
5539
|
+
var UserController = class {
|
|
5540
|
+
constructor(userService) {
|
|
5541
|
+
this.userService = userService;
|
|
5542
|
+
}
|
|
5543
|
+
async list(request, reply) {
|
|
5544
|
+
const query = validateQuery(userQuerySchema, request.query);
|
|
5545
|
+
const pagination = parsePaginationParams(query);
|
|
5546
|
+
const filters = {
|
|
5547
|
+
status: query.status,
|
|
5548
|
+
role: query.role,
|
|
5549
|
+
search: query.search,
|
|
5550
|
+
emailVerified: query.emailVerified
|
|
5551
|
+
};
|
|
5552
|
+
const result = await this.userService.findMany(pagination, filters);
|
|
5553
|
+
success2(reply, result);
|
|
5554
|
+
}
|
|
5555
|
+
async getById(request, reply) {
|
|
5556
|
+
const user = await this.userService.findById(request.params.id);
|
|
5557
|
+
if (!user) {
|
|
5558
|
+
return reply.status(404).send({
|
|
5559
|
+
success: false,
|
|
5560
|
+
message: "User not found"
|
|
5561
|
+
});
|
|
5562
|
+
}
|
|
5563
|
+
success2(reply, omitPassword(user));
|
|
5564
|
+
}
|
|
5565
|
+
async update(request, reply) {
|
|
5566
|
+
const data = validateBody(updateUserSchema, request.body);
|
|
5567
|
+
const user = await this.userService.update(request.params.id, data);
|
|
5568
|
+
success2(reply, omitPassword(user));
|
|
5569
|
+
}
|
|
5570
|
+
async delete(request, reply) {
|
|
5571
|
+
const authRequest = request;
|
|
5572
|
+
if (authRequest.user.id === request.params.id) {
|
|
5573
|
+
throw new ForbiddenError("Cannot delete your own account");
|
|
5574
|
+
}
|
|
5575
|
+
await this.userService.delete(request.params.id);
|
|
5576
|
+
noContent(reply);
|
|
5577
|
+
}
|
|
5578
|
+
async suspend(request, reply) {
|
|
5579
|
+
const authRequest = request;
|
|
5580
|
+
if (authRequest.user.id === request.params.id) {
|
|
5581
|
+
throw new ForbiddenError("Cannot suspend your own account");
|
|
5582
|
+
}
|
|
5583
|
+
const user = await this.userService.suspend(request.params.id);
|
|
5584
|
+
const userData = omitPassword(user);
|
|
5585
|
+
success2(reply, userData);
|
|
5586
|
+
}
|
|
5587
|
+
async ban(request, reply) {
|
|
5588
|
+
const authRequest = request;
|
|
5589
|
+
if (authRequest.user.id === request.params.id) {
|
|
5590
|
+
throw new ForbiddenError("Cannot ban your own account");
|
|
5591
|
+
}
|
|
5592
|
+
const user = await this.userService.ban(request.params.id);
|
|
5593
|
+
const userData = omitPassword(user);
|
|
5594
|
+
success2(reply, userData);
|
|
5595
|
+
}
|
|
5596
|
+
async activate(request, reply) {
|
|
5597
|
+
const user = await this.userService.activate(request.params.id);
|
|
5598
|
+
const userData = omitPassword(user);
|
|
5599
|
+
success2(reply, userData);
|
|
5600
|
+
}
|
|
5601
|
+
// Profile routes (for authenticated user)
|
|
5602
|
+
async getProfile(request, reply) {
|
|
5603
|
+
const authRequest = request;
|
|
5604
|
+
const user = await this.userService.findById(authRequest.user.id);
|
|
5605
|
+
if (!user) {
|
|
5606
|
+
return reply.status(404).send({
|
|
5607
|
+
success: false,
|
|
5608
|
+
message: "User not found"
|
|
5609
|
+
});
|
|
5610
|
+
}
|
|
5611
|
+
const userData = omitPassword(user);
|
|
5612
|
+
success2(reply, userData);
|
|
5613
|
+
}
|
|
5614
|
+
async updateProfile(request, reply) {
|
|
5615
|
+
const authRequest = request;
|
|
5616
|
+
const data = validateBody(updateProfileSchema, request.body);
|
|
5617
|
+
const user = await this.userService.update(authRequest.user.id, data);
|
|
5618
|
+
const userData = omitPassword(user);
|
|
5619
|
+
success2(reply, userData);
|
|
5620
|
+
}
|
|
5621
|
+
};
|
|
5622
|
+
function createUserController(userService) {
|
|
5623
|
+
return new UserController(userService);
|
|
5624
|
+
}
|
|
5625
|
+
|
|
5626
|
+
// src/modules/user/user.routes.ts
|
|
5627
|
+
var idParamsSchema = {
|
|
5628
|
+
type: "object",
|
|
5629
|
+
properties: {
|
|
5630
|
+
id: { type: "string" }
|
|
5631
|
+
},
|
|
5632
|
+
required: ["id"]
|
|
5633
|
+
};
|
|
5634
|
+
function registerUserRoutes(app, controller, authService) {
|
|
5635
|
+
const authenticate = createAuthMiddleware(authService);
|
|
5636
|
+
const isAdmin = createRoleMiddleware(["admin", "super_admin"]);
|
|
5637
|
+
const isModerator = createRoleMiddleware(["moderator", "admin", "super_admin"]);
|
|
5638
|
+
app.get("/profile", { preHandler: [authenticate] }, controller.getProfile.bind(controller));
|
|
5639
|
+
app.patch("/profile", { preHandler: [authenticate] }, controller.updateProfile.bind(controller));
|
|
5640
|
+
app.get("/users", { preHandler: [authenticate, isModerator] }, controller.list.bind(controller));
|
|
5641
|
+
app.get(
|
|
5642
|
+
"/users/:id",
|
|
5643
|
+
{ preHandler: [authenticate, isModerator], schema: { params: idParamsSchema } },
|
|
5644
|
+
async (request, reply) => {
|
|
5645
|
+
return controller.getById(request, reply);
|
|
5646
|
+
}
|
|
5647
|
+
);
|
|
5648
|
+
app.patch(
|
|
5649
|
+
"/users/:id",
|
|
5650
|
+
{ preHandler: [authenticate, isAdmin], schema: { params: idParamsSchema } },
|
|
5651
|
+
async (request, reply) => {
|
|
5652
|
+
return controller.update(request, reply);
|
|
5653
|
+
}
|
|
5654
|
+
);
|
|
5655
|
+
app.delete(
|
|
5656
|
+
"/users/:id",
|
|
5657
|
+
{ preHandler: [authenticate, isAdmin], schema: { params: idParamsSchema } },
|
|
5658
|
+
async (request, reply) => {
|
|
5659
|
+
return controller.delete(request, reply);
|
|
5660
|
+
}
|
|
5661
|
+
);
|
|
5662
|
+
app.post(
|
|
5663
|
+
"/users/:id/suspend",
|
|
5664
|
+
{ preHandler: [authenticate, isAdmin], schema: { params: idParamsSchema } },
|
|
5665
|
+
async (request, reply) => {
|
|
5666
|
+
return controller.suspend(request, reply);
|
|
5667
|
+
}
|
|
5668
|
+
);
|
|
5669
|
+
app.post(
|
|
5670
|
+
"/users/:id/ban",
|
|
5671
|
+
{ preHandler: [authenticate, isAdmin], schema: { params: idParamsSchema } },
|
|
5672
|
+
async (request, reply) => {
|
|
5673
|
+
return controller.ban(request, reply);
|
|
5674
|
+
}
|
|
5675
|
+
);
|
|
5676
|
+
app.post(
|
|
5677
|
+
"/users/:id/activate",
|
|
5678
|
+
{ preHandler: [authenticate, isAdmin], schema: { params: idParamsSchema } },
|
|
5679
|
+
async (request, reply) => {
|
|
5680
|
+
return controller.activate(request, reply);
|
|
5681
|
+
}
|
|
5682
|
+
);
|
|
5683
|
+
}
|
|
5684
|
+
|
|
5685
|
+
// src/modules/user/index.ts
|
|
5686
|
+
async function registerUserModule(app, authService) {
|
|
5687
|
+
const repository = createUserRepository();
|
|
5688
|
+
const userService = createUserService(repository);
|
|
5689
|
+
const userController = createUserController(userService);
|
|
5690
|
+
registerUserRoutes(app, userController, authService);
|
|
5691
|
+
logger.info("User module registered");
|
|
5692
|
+
}
|
|
5693
|
+
|
|
5694
|
+
// src/cli/utils/docs-generator.ts
|
|
5695
|
+
async function generateDocs(outputPath = "openapi.json", silent = false) {
|
|
5696
|
+
const spinner = silent ? null : ora5("Generating OpenAPI documentation...").start();
|
|
5697
|
+
try {
|
|
5698
|
+
const server = createServer({
|
|
5699
|
+
port: config.server.port,
|
|
5700
|
+
host: config.server.host
|
|
5701
|
+
});
|
|
5702
|
+
const app = server.instance;
|
|
5703
|
+
registerErrorHandler(app);
|
|
5704
|
+
await registerSecurity(app);
|
|
5705
|
+
await registerSwagger(app, {
|
|
5706
|
+
title: "Servcraft API",
|
|
5707
|
+
description: "API documentation generated by Servcraft",
|
|
5708
|
+
version: "1.0.0"
|
|
5709
|
+
});
|
|
5710
|
+
await registerAuthModule(app);
|
|
5711
|
+
const authService = createAuthService(app);
|
|
5712
|
+
await registerUserModule(app, authService);
|
|
5713
|
+
await app.ready();
|
|
5714
|
+
const spec = app.swagger();
|
|
5715
|
+
const absoluteOutput = path7.resolve(outputPath);
|
|
5716
|
+
await fs6.mkdir(path7.dirname(absoluteOutput), { recursive: true });
|
|
5717
|
+
await fs6.writeFile(absoluteOutput, JSON.stringify(spec, null, 2), "utf8");
|
|
5718
|
+
spinner?.succeed(`OpenAPI spec generated at ${absoluteOutput}`);
|
|
5719
|
+
await app.close();
|
|
5720
|
+
return absoluteOutput;
|
|
5721
|
+
} catch (error2) {
|
|
5722
|
+
spinner?.fail("Failed to generate OpenAPI documentation");
|
|
5723
|
+
throw error2;
|
|
5724
|
+
}
|
|
5725
|
+
}
|
|
5726
|
+
|
|
5727
|
+
// src/cli/commands/docs.ts
|
|
5728
|
+
var docsCommand = new Command5("docs").description("API documentation commands");
|
|
5729
|
+
docsCommand.command("generate").alias("gen").description("Generate OpenAPI/Swagger documentation").option("-o, --output <file>", "Output file path", "openapi.json").option("-f, --format <format>", "Output format: json, yaml", "json").action(async (options) => {
|
|
5730
|
+
try {
|
|
5731
|
+
const outputPath = await generateDocs(options.output, false);
|
|
5732
|
+
if (options.format === "yaml") {
|
|
5733
|
+
const jsonContent = await fs7.readFile(outputPath, "utf-8");
|
|
5734
|
+
const spec = JSON.parse(jsonContent);
|
|
5735
|
+
const yamlPath = outputPath.replace(".json", ".yaml");
|
|
5736
|
+
await fs7.writeFile(yamlPath, jsonToYaml(spec));
|
|
5737
|
+
success(`YAML documentation generated: ${yamlPath}`);
|
|
5738
|
+
}
|
|
5739
|
+
console.log("\n\u{1F4DA} Documentation URLs:");
|
|
5740
|
+
info(" Swagger UI: http://localhost:3000/docs");
|
|
5741
|
+
info(" OpenAPI JSON: http://localhost:3000/docs/json");
|
|
5742
|
+
} catch (err) {
|
|
5743
|
+
error(err instanceof Error ? err.message : String(err));
|
|
5744
|
+
}
|
|
5745
|
+
});
|
|
5746
|
+
docsCommand.option("-o, --output <path>", "Output file path", "openapi.json").action(async (options) => {
|
|
5747
|
+
if (options.output) {
|
|
5748
|
+
try {
|
|
5749
|
+
const outputPath = await generateDocs(options.output);
|
|
5750
|
+
success(`Documentation written to ${outputPath}`);
|
|
5751
|
+
} catch (err) {
|
|
5752
|
+
error(err instanceof Error ? err.message : String(err));
|
|
5753
|
+
process.exitCode = 1;
|
|
5754
|
+
}
|
|
5755
|
+
}
|
|
5756
|
+
});
|
|
5757
|
+
docsCommand.command("export").description("Export documentation to Postman, Insomnia, or YAML").option("-f, --format <format>", "Export format: postman, insomnia, yaml", "postman").option("-o, --output <file>", "Output file path").action(async (options) => {
|
|
5758
|
+
const spinner = ora6("Exporting documentation...").start();
|
|
5759
|
+
try {
|
|
5760
|
+
const projectRoot = getProjectRoot();
|
|
5761
|
+
const specPath = path8.join(projectRoot, "openapi.json");
|
|
5762
|
+
try {
|
|
5763
|
+
await fs7.access(specPath);
|
|
5764
|
+
} catch {
|
|
5765
|
+
spinner.text = "Generating OpenAPI spec first...";
|
|
5766
|
+
await generateDocs("openapi.json", true);
|
|
5767
|
+
}
|
|
5768
|
+
const specContent = await fs7.readFile(specPath, "utf-8");
|
|
5769
|
+
const spec = JSON.parse(specContent);
|
|
5770
|
+
let output;
|
|
5771
|
+
let defaultName;
|
|
5772
|
+
switch (options.format) {
|
|
5773
|
+
case "postman":
|
|
5774
|
+
output = JSON.stringify(convertToPostman(spec), null, 2);
|
|
5775
|
+
defaultName = "postman_collection.json";
|
|
5776
|
+
break;
|
|
5777
|
+
case "insomnia":
|
|
5778
|
+
output = JSON.stringify(convertToInsomnia(spec), null, 2);
|
|
5779
|
+
defaultName = "insomnia_collection.json";
|
|
5780
|
+
break;
|
|
5781
|
+
case "yaml":
|
|
5782
|
+
output = jsonToYaml(spec);
|
|
5783
|
+
defaultName = "openapi.yaml";
|
|
5784
|
+
break;
|
|
5785
|
+
default:
|
|
5786
|
+
throw new Error(`Unknown format: ${options.format}`);
|
|
5787
|
+
}
|
|
5788
|
+
const outPath = path8.join(projectRoot, options.output || defaultName);
|
|
5789
|
+
await fs7.writeFile(outPath, output);
|
|
5790
|
+
spinner.succeed(`Exported to: ${options.output || defaultName}`);
|
|
5791
|
+
if (options.format === "postman") {
|
|
5792
|
+
info("\n Import in Postman: File > Import > Select file");
|
|
5793
|
+
}
|
|
5794
|
+
} catch (err) {
|
|
5795
|
+
spinner.fail("Export failed");
|
|
5796
|
+
error(err instanceof Error ? err.message : String(err));
|
|
5797
|
+
}
|
|
5798
|
+
});
|
|
5799
|
+
docsCommand.command("status").description("Show documentation status").action(async () => {
|
|
5800
|
+
const projectRoot = getProjectRoot();
|
|
5801
|
+
console.log(chalk6.bold("\n\u{1F4CA} Documentation Status\n"));
|
|
5802
|
+
const specPath = path8.join(projectRoot, "openapi.json");
|
|
5803
|
+
try {
|
|
5804
|
+
const stat2 = await fs7.stat(specPath);
|
|
5805
|
+
success(
|
|
5806
|
+
`openapi.json exists (${formatBytes(stat2.size)}, modified ${formatDate(stat2.mtime)})`
|
|
5807
|
+
);
|
|
5808
|
+
const content = await fs7.readFile(specPath, "utf-8");
|
|
5809
|
+
const spec = JSON.parse(content);
|
|
5810
|
+
const pathCount = Object.keys(spec.paths || {}).length;
|
|
5811
|
+
info(` ${pathCount} endpoints documented`);
|
|
5812
|
+
} catch {
|
|
5813
|
+
warn('openapi.json not found - run "servcraft docs generate"');
|
|
5814
|
+
}
|
|
5815
|
+
console.log("\n\u{1F4CC} Commands:");
|
|
5816
|
+
info(" servcraft docs generate Generate OpenAPI spec");
|
|
5817
|
+
info(" servcraft docs export Export to Postman/Insomnia");
|
|
5818
|
+
});
|
|
5819
|
+
function jsonToYaml(obj, indent = 0) {
|
|
5820
|
+
const spaces = " ".repeat(indent);
|
|
5821
|
+
if (obj === null || obj === void 0) return "null";
|
|
5822
|
+
if (typeof obj === "string") {
|
|
5823
|
+
if (obj.includes("\n") || obj.includes(":") || obj.includes("#")) {
|
|
5824
|
+
return `"${obj.replace(/"/g, '\\"')}"`;
|
|
5825
|
+
}
|
|
5826
|
+
return obj || '""';
|
|
5827
|
+
}
|
|
5828
|
+
if (typeof obj === "number" || typeof obj === "boolean") return String(obj);
|
|
5829
|
+
if (Array.isArray(obj)) {
|
|
5830
|
+
if (obj.length === 0) return "[]";
|
|
5831
|
+
return obj.map((item) => `${spaces}- ${jsonToYaml(item, indent + 1).trimStart()}`).join("\n");
|
|
5832
|
+
}
|
|
5833
|
+
if (typeof obj === "object") {
|
|
5834
|
+
const entries = Object.entries(obj);
|
|
5835
|
+
if (entries.length === 0) return "{}";
|
|
5836
|
+
return entries.map(([key, value]) => {
|
|
5837
|
+
const valueStr = jsonToYaml(value, indent + 1);
|
|
5838
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length > 0) {
|
|
5839
|
+
return `${spaces}${key}:
|
|
5840
|
+
${valueStr}`;
|
|
5841
|
+
}
|
|
5842
|
+
return `${spaces}${key}: ${valueStr}`;
|
|
5843
|
+
}).join("\n");
|
|
5844
|
+
}
|
|
5845
|
+
return String(obj);
|
|
5846
|
+
}
|
|
5847
|
+
function convertToPostman(spec) {
|
|
5848
|
+
const baseUrl = spec.servers?.[0]?.url || "http://localhost:3000";
|
|
5849
|
+
const items = [];
|
|
5850
|
+
for (const [pathUrl, methods] of Object.entries(spec.paths || {})) {
|
|
5851
|
+
for (const [method, details] of Object.entries(methods)) {
|
|
5852
|
+
items.push({
|
|
5853
|
+
name: details.summary || `${method.toUpperCase()} ${pathUrl}`,
|
|
5854
|
+
request: {
|
|
5855
|
+
method: method.toUpperCase(),
|
|
5856
|
+
header: [
|
|
5857
|
+
{ key: "Content-Type", value: "application/json" },
|
|
5858
|
+
{ key: "Authorization", value: "Bearer {{token}}" }
|
|
5859
|
+
],
|
|
5860
|
+
url: {
|
|
5861
|
+
raw: `{{baseUrl}}${pathUrl}`,
|
|
5862
|
+
host: ["{{baseUrl}}"],
|
|
5863
|
+
path: pathUrl.split("/").filter(Boolean)
|
|
5864
|
+
},
|
|
5865
|
+
...details.requestBody ? { body: { mode: "raw", raw: "{}" } } : {}
|
|
5866
|
+
}
|
|
5867
|
+
});
|
|
5868
|
+
}
|
|
5869
|
+
}
|
|
5870
|
+
return {
|
|
5871
|
+
info: {
|
|
5872
|
+
name: spec.info.title,
|
|
5873
|
+
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
|
5874
|
+
},
|
|
5875
|
+
item: items,
|
|
5876
|
+
variable: [
|
|
5877
|
+
{ key: "baseUrl", value: baseUrl },
|
|
5878
|
+
{ key: "token", value: "" }
|
|
5879
|
+
]
|
|
5880
|
+
};
|
|
5881
|
+
}
|
|
5882
|
+
function convertToInsomnia(spec) {
|
|
5883
|
+
const baseUrl = spec.servers?.[0]?.url || "http://localhost:3000";
|
|
5884
|
+
const resources = [
|
|
5885
|
+
{ _type: "environment", name: "Base Environment", data: { baseUrl, token: "" } }
|
|
5886
|
+
];
|
|
5887
|
+
for (const [pathUrl, methods] of Object.entries(spec.paths || {})) {
|
|
5888
|
+
for (const [method, details] of Object.entries(methods)) {
|
|
5889
|
+
resources.push({
|
|
5890
|
+
_type: "request",
|
|
5891
|
+
name: details.summary || `${method.toUpperCase()} ${pathUrl}`,
|
|
5892
|
+
method: method.toUpperCase(),
|
|
5893
|
+
url: `{{ baseUrl }}${pathUrl}`,
|
|
5894
|
+
headers: [
|
|
5895
|
+
{ name: "Content-Type", value: "application/json" },
|
|
5896
|
+
{ name: "Authorization", value: "Bearer {{ token }}" }
|
|
5897
|
+
]
|
|
5898
|
+
});
|
|
5899
|
+
}
|
|
5900
|
+
}
|
|
5901
|
+
return { _type: "export", __export_format: 4, resources };
|
|
5902
|
+
}
|
|
5903
|
+
function formatBytes(bytes) {
|
|
5904
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
5905
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
5906
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
5907
|
+
}
|
|
5908
|
+
function formatDate(date) {
|
|
5909
|
+
return date.toLocaleDateString("en-US", {
|
|
5910
|
+
month: "short",
|
|
5911
|
+
day: "numeric",
|
|
5912
|
+
hour: "2-digit",
|
|
5913
|
+
minute: "2-digit"
|
|
5914
|
+
});
|
|
5915
|
+
}
|
|
5916
|
+
|
|
3795
5917
|
// src/cli/index.ts
|
|
3796
|
-
var program = new
|
|
5918
|
+
var program = new Command6();
|
|
3797
5919
|
program.name("servcraft").description("Servcraft - A modular Node.js backend framework CLI").version("0.1.0");
|
|
3798
5920
|
program.addCommand(initCommand);
|
|
3799
5921
|
program.addCommand(generateCommand);
|
|
3800
5922
|
program.addCommand(addModuleCommand);
|
|
3801
5923
|
program.addCommand(dbCommand);
|
|
5924
|
+
program.addCommand(docsCommand);
|
|
3802
5925
|
program.parse();
|
|
3803
5926
|
//# sourceMappingURL=index.js.map
|