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