next-js-backend 1.0.0
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/LICENSE +21 -0
- package/README.md +402 -0
- package/dist/docs/build.d.ts +2 -0
- package/dist/docs/dist/chunk-8whee738.d.ts +1 -0
- package/dist/docs/src/App.d.ts +2 -0
- package/dist/docs/src/components/MarkdownRenderer.d.ts +5 -0
- package/dist/docs/src/components/layout/DocsLayout.d.ts +1 -0
- package/dist/docs/src/components/ui/button.d.ts +10 -0
- package/dist/docs/src/components/ui/card.d.ts +9 -0
- package/dist/docs/src/components/ui/input.d.ts +3 -0
- package/dist/docs/src/components/ui/label.d.ts +4 -0
- package/dist/docs/src/components/ui/select.d.ts +15 -0
- package/dist/docs/src/components/ui/textarea.d.ts +3 -0
- package/dist/docs/src/frontend.d.ts +7 -0
- package/dist/docs/src/index.d.ts +1 -0
- package/dist/docs/src/lib/utils.d.ts +2 -0
- package/dist/docs/src/pages/DocsPages.d.ts +14 -0
- package/dist/docs/src/pages/index.d.ts +1 -0
- package/dist/docs/src/test/test-build.d.ts +1 -0
- package/dist/docs/src/test/test-mdx.d.ts +1 -0
- package/dist/docs/test-build.d.ts +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +61316 -0
- package/dist/samples/basic-crud/app.module.d.ts +2 -0
- package/dist/samples/basic-crud/index.d.ts +1 -0
- package/dist/samples/basic-crud/users.controller.d.ts +23 -0
- package/dist/samples/basic-crud/users.service.d.ts +18 -0
- package/dist/samples/class-validator-pipe/admins.controller.d.ts +12 -0
- package/dist/samples/class-validator-pipe/app.module.d.ts +2 -0
- package/dist/samples/class-validator-pipe/dto/create-admin.dto.d.ts +5 -0
- package/dist/samples/class-validator-pipe/index.d.ts +1 -0
- package/dist/samples/guards-interceptors/app.module.d.ts +2 -0
- package/dist/samples/guards-interceptors/auth.guard.d.ts +5 -0
- package/dist/samples/guards-interceptors/dashboard.controller.d.ts +11 -0
- package/dist/samples/guards-interceptors/index.d.ts +1 -0
- package/dist/samples/guards-interceptors/logging.interceptor.d.ts +5 -0
- package/dist/samples/swagger-openapi/app.module.d.ts +2 -0
- package/dist/samples/swagger-openapi/books.controller.d.ts +23 -0
- package/dist/samples/swagger-openapi/books.service.d.ts +18 -0
- package/dist/samples/swagger-openapi/index.d.ts +1 -0
- package/dist/src/auth/__tests__/password.service.test.d.ts +1 -0
- package/dist/src/auth/auth.guard.d.ts +8 -0
- package/dist/src/auth/index.d.ts +4 -0
- package/dist/src/auth/jwt.module.d.ts +10 -0
- package/dist/src/auth/jwt.service.d.ts +25 -0
- package/dist/src/auth/password.service.d.ts +32 -0
- package/dist/src/config/config.module.d.ts +10 -0
- package/dist/src/config/config.service.d.ts +25 -0
- package/dist/src/config/index.d.ts +2 -0
- package/dist/src/constants.d.ts +16 -0
- package/dist/src/decorators/catch.decorator.d.ts +10 -0
- package/dist/src/decorators/controller.decorator.d.ts +2 -0
- package/dist/src/decorators/filter.decorator.d.ts +7 -0
- package/dist/src/decorators/guard.decorator.d.ts +3 -0
- package/dist/src/decorators/interceptor.decorator.d.ts +3 -0
- package/dist/src/decorators/method.decorator.d.ts +19 -0
- package/dist/src/decorators/module.decorator.d.ts +16 -0
- package/dist/src/decorators/param.decorator.d.ts +37 -0
- package/dist/src/decorators/pipe.decorator.d.ts +3 -0
- package/dist/src/decorators/schema.decorator.d.ts +10 -0
- package/dist/src/di/__tests__/container.test.d.ts +1 -0
- package/dist/src/di/container.d.ts +23 -0
- package/dist/src/di/inject.decorator.d.ts +7 -0
- package/dist/src/di/injectable.decorator.d.ts +2 -0
- package/dist/src/di/provider.d.ts +20 -0
- package/dist/src/exceptions/http.exception.d.ts +13 -0
- package/dist/src/exceptions/index.d.ts +17 -0
- package/dist/src/exceptions/validation.pipe.d.ts +15 -0
- package/dist/src/factory/__tests__/exception-filters.test.d.ts +1 -0
- package/dist/src/factory/__tests__/file-upload.test.d.ts +1 -0
- package/dist/src/factory/elysia-factory.d.ts +26 -0
- package/dist/src/interfaces.d.ts +26 -0
- package/dist/src/services/logger.service.d.ts +39 -0
- package/dist/src/session/__tests__/session.module.test.d.ts +1 -0
- package/dist/src/session/session.module.d.ts +7 -0
- package/dist/src/session/session.options.d.ts +35 -0
- package/dist/src/session/session.service.d.ts +20 -0
- package/dist/src/session/session.store.d.ts +39 -0
- package/dist/test.d.ts +1 -0
- package/dist/testing/e2e/auth.test.d.ts +1 -0
- package/dist/testing/e2e/config.test.d.ts +1 -0
- package/dist/testing/e2e/di.test.d.ts +1 -0
- package/dist/testing/e2e/guards-interceptors.test.d.ts +1 -0
- package/dist/testing/e2e/routing.test.d.ts +1 -0
- package/dist/testing/e2e/validation.test.d.ts +1 -0
- package/index.ts +24 -0
- package/package.json +61 -0
- package/src/auth/__tests__/password.service.test.ts +38 -0
- package/src/auth/auth.guard.ts +34 -0
- package/src/auth/index.ts +4 -0
- package/src/auth/jwt.module.ts +24 -0
- package/src/auth/jwt.service.ts +65 -0
- package/src/auth/password.service.ts +48 -0
- package/src/config/config.module.ts +24 -0
- package/src/config/config.service.ts +78 -0
- package/src/config/index.ts +2 -0
- package/src/constants.ts +16 -0
- package/src/decorators/catch.decorator.ts +16 -0
- package/src/decorators/controller.decorator.ts +14 -0
- package/src/decorators/filter.decorator.ts +22 -0
- package/src/decorators/guard.decorator.ts +19 -0
- package/src/decorators/interceptor.decorator.ts +19 -0
- package/src/decorators/method.decorator.ts +37 -0
- package/src/decorators/module.decorator.ts +28 -0
- package/src/decorators/param.decorator.ts +80 -0
- package/src/decorators/pipe.decorator.ts +16 -0
- package/src/decorators/schema.decorator.ts +19 -0
- package/src/di/__tests__/container.test.ts +106 -0
- package/src/di/container.ts +98 -0
- package/src/di/inject.decorator.ts +14 -0
- package/src/di/injectable.decorator.ts +9 -0
- package/src/di/provider.ts +31 -0
- package/src/exceptions/http.exception.ts +29 -0
- package/src/exceptions/index.ts +32 -0
- package/src/exceptions/validation.pipe.ts +68 -0
- package/src/factory/__tests__/exception-filters.test.ts +102 -0
- package/src/factory/__tests__/file-upload.test.ts +70 -0
- package/src/factory/elysia-factory.ts +445 -0
- package/src/globals.d.ts +7 -0
- package/src/interfaces.ts +33 -0
- package/src/services/logger.service.ts +135 -0
- package/src/session/__tests__/session.module.test.ts +55 -0
- package/src/session/session.module.ts +43 -0
- package/src/session/session.options.ts +40 -0
- package/src/session/session.service.ts +47 -0
- package/src/session/session.store.ts +73 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface LoggerService {
|
|
2
|
+
log(message: unknown, context?: string): any;
|
|
3
|
+
error(message: unknown, trace?: string, context?: string): any;
|
|
4
|
+
warn(message: unknown, context?: string): any;
|
|
5
|
+
debug?(message: unknown, context?: string): any;
|
|
6
|
+
verbose?(message: unknown, context?: string): any;
|
|
7
|
+
}
|
|
8
|
+
export type LogLevel = 'log' | 'error' | 'warn' | 'debug' | 'verbose';
|
|
9
|
+
export declare class ConsoleLogger implements LoggerService {
|
|
10
|
+
private static lastTimestamp;
|
|
11
|
+
protected context?: string;
|
|
12
|
+
constructor(context?: string);
|
|
13
|
+
setContext(context: string): void;
|
|
14
|
+
log(message: unknown, context?: string): void;
|
|
15
|
+
error(message: unknown, trace?: string, context?: string): void;
|
|
16
|
+
warn(message: unknown, context?: string): void;
|
|
17
|
+
debug(message: unknown, context?: string): void;
|
|
18
|
+
verbose(message: unknown, context?: string): void;
|
|
19
|
+
protected printMessage(message: unknown, color: string, context: string | undefined, level: string): void;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A static wrapper around the default logger for use outside of DI context.
|
|
23
|
+
*/
|
|
24
|
+
export declare class Logger {
|
|
25
|
+
private static staticInstance;
|
|
26
|
+
private instanceLogger;
|
|
27
|
+
constructor(context?: string);
|
|
28
|
+
static overrideLogger(logger: LoggerService): void;
|
|
29
|
+
log(message: unknown, context?: string): void;
|
|
30
|
+
error(message: unknown, trace?: string, context?: string): void;
|
|
31
|
+
warn(message: unknown, context?: string): void;
|
|
32
|
+
debug(message: unknown, context?: string): void;
|
|
33
|
+
verbose(message: unknown, context?: string): void;
|
|
34
|
+
static log(message: unknown, context?: string): void;
|
|
35
|
+
static error(message: unknown, trace?: string, context?: string): void;
|
|
36
|
+
static warn(message: unknown, context?: string): void;
|
|
37
|
+
static debug(message: unknown, context?: string): void;
|
|
38
|
+
static verbose(message: unknown, context?: string): void;
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { DynamicModule } from '../interfaces';
|
|
2
|
+
import type { SessionModuleOptions } from './session.options';
|
|
3
|
+
export declare const SESSION_OPTIONS = "SESSION_OPTIONS";
|
|
4
|
+
export declare const SESSION_STORE = "SESSION_STORE";
|
|
5
|
+
export declare class SessionModule {
|
|
6
|
+
static register(options?: SessionModuleOptions): DynamicModule;
|
|
7
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Type } from '../di/provider';
|
|
2
|
+
import type { SessionStore } from './session.store';
|
|
3
|
+
export interface SessionModuleOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Secret string used to sign the cookie.`
|
|
6
|
+
* If provided, the cookie becomes tamper-proof (HttpOnly and Signed).
|
|
7
|
+
*/
|
|
8
|
+
secret?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Custom storage engine for tracking sessions instead of placing payload directly
|
|
11
|
+
* into the cookie (which has 4KB size limits).
|
|
12
|
+
*
|
|
13
|
+
* If omitted, defaults to an `InMemorySessionStore`.
|
|
14
|
+
*/
|
|
15
|
+
store?: SessionStore | Type<SessionStore>;
|
|
16
|
+
/**
|
|
17
|
+
* The name of the session cookie.
|
|
18
|
+
* @default 'sid'
|
|
19
|
+
*/
|
|
20
|
+
cookieName?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Default session expiration time in seconds.
|
|
23
|
+
* @default 86400 (1 day)
|
|
24
|
+
*/
|
|
25
|
+
ttl?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Additional Elysia Cookie configuration
|
|
28
|
+
*/
|
|
29
|
+
cookieOptions?: {
|
|
30
|
+
path?: string;
|
|
31
|
+
domain?: string;
|
|
32
|
+
secure?: boolean;
|
|
33
|
+
sameSite?: 'lax' | 'strict' | 'none';
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { SessionStore } from './session.store';
|
|
2
|
+
import type { SessionModuleOptions } from './session.options';
|
|
3
|
+
import type { SessionData } from '../globals';
|
|
4
|
+
export declare class SessionService {
|
|
5
|
+
private readonly store;
|
|
6
|
+
private readonly options;
|
|
7
|
+
constructor(store: SessionStore, options: SessionModuleOptions);
|
|
8
|
+
/**
|
|
9
|
+
* Creates or writes data to a session and manages the corresponding Cookie payload.
|
|
10
|
+
*/
|
|
11
|
+
createSession(data: SessionData): Promise<string>;
|
|
12
|
+
/**
|
|
13
|
+
* Loads session from storage.
|
|
14
|
+
*/
|
|
15
|
+
getSession(sessionId: string): Promise<SessionData | null>;
|
|
16
|
+
/**
|
|
17
|
+
* Destroy the active backend session instance completely.
|
|
18
|
+
*/
|
|
19
|
+
destroySession(sessionId: string): Promise<void>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { SessionData } from '../globals';
|
|
2
|
+
/**
|
|
3
|
+
* Abstract class that robust session stores must extend.
|
|
4
|
+
* Users can create custom classes (Redis, Prisma, BunORM) implementing this
|
|
5
|
+
* to provide centralized state distribution.
|
|
6
|
+
*/
|
|
7
|
+
export declare abstract class SessionStore {
|
|
8
|
+
/**
|
|
9
|
+
* Retrieves a session from the store given a session ID.
|
|
10
|
+
*/
|
|
11
|
+
abstract get(sessionId: string): Promise<SessionData | null>;
|
|
12
|
+
/**
|
|
13
|
+
* Upserts a session into the store given a session ID and session data.
|
|
14
|
+
*
|
|
15
|
+
* @param sessionId Explicit session ID attached to the Cookie.
|
|
16
|
+
* @param data The payload attached to the user.
|
|
17
|
+
* @param ttl Expected Time To Live in seconds. 0 indicates non-expiring.
|
|
18
|
+
*/
|
|
19
|
+
abstract set(sessionId: string, data: SessionData, ttl: number): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Specifically destroys a running session by ID.
|
|
22
|
+
*/
|
|
23
|
+
abstract destroy(sessionId: string): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Refresh a session's Time To Live if accessed.
|
|
26
|
+
*/
|
|
27
|
+
abstract touch?(sessionId: string, ttl: number): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A built-in, non-persistent In-Memory Session Storage mechanism.
|
|
31
|
+
* Ideal for local development or basic deployment without a DB dependency.
|
|
32
|
+
*/
|
|
33
|
+
export declare class InMemorySessionStore implements SessionStore {
|
|
34
|
+
private store;
|
|
35
|
+
get(sessionId: string): Promise<SessionData | null>;
|
|
36
|
+
set(sessionId: string, data: SessionData, ttl: number): Promise<void>;
|
|
37
|
+
destroy(sessionId: string): Promise<void>;
|
|
38
|
+
touch(sessionId: string, ttl: number): Promise<void>;
|
|
39
|
+
}
|
package/dist/test.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export * from './src/constants';
|
|
2
|
+
export * from './src/interfaces';
|
|
3
|
+
export * from './src/decorators/controller.decorator';
|
|
4
|
+
export { Get, Post, Put, Delete, Patch, Options, Head, All, RequestMethod } from './src/decorators/method.decorator';
|
|
5
|
+
export * from './src/decorators/module.decorator';
|
|
6
|
+
export * from './src/decorators/param.decorator';
|
|
7
|
+
export * from './src/decorators/schema.decorator';
|
|
8
|
+
export * from './src/decorators/guard.decorator';
|
|
9
|
+
export * from './src/decorators/interceptor.decorator';
|
|
10
|
+
export * from './src/decorators/pipe.decorator';
|
|
11
|
+
export * from './src/decorators/catch.decorator';
|
|
12
|
+
export * from './src/decorators/filter.decorator';
|
|
13
|
+
export * from './src/di/injectable.decorator';
|
|
14
|
+
export * from './src/di/container';
|
|
15
|
+
export * from './src/exceptions';
|
|
16
|
+
export * from './src/exceptions/validation.pipe';
|
|
17
|
+
export * from './src/factory/elysia-factory';export * from './src/services/logger.service';
|
|
18
|
+
export * from './src/config/config.service';
|
|
19
|
+
export * from './src/config/config.module';
|
|
20
|
+
export * from './src/auth';
|
|
21
|
+
export * from './src/session/session.module';
|
|
22
|
+
export * from './src/session/session.service';
|
|
23
|
+
export * from './src/session/session.store';
|
|
24
|
+
export * from './src/session/session.options';
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "next-js-backend",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"module": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "bun build ./index.ts --outdir ./dist --target node && bun x tsc --emitDeclarationOnly",
|
|
17
|
+
"prepublishOnly": "bun run build"
|
|
18
|
+
},
|
|
19
|
+
"description": "A high-performance NestJS-like backend library for Bun and Next.js Edge, powered by Elysia.",
|
|
20
|
+
"author": "Tuan Nguyen",
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"keywords": [
|
|
23
|
+
"elysia",
|
|
24
|
+
"nestjs",
|
|
25
|
+
"bun",
|
|
26
|
+
"nextjs",
|
|
27
|
+
"library",
|
|
28
|
+
"edge",
|
|
29
|
+
"decorators",
|
|
30
|
+
"dependency-injection"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/tuannc/next-js-backend.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/tuannc/next-js-backend/issues"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"src",
|
|
42
|
+
"index.ts",
|
|
43
|
+
"globals.d.ts",
|
|
44
|
+
"README.md"
|
|
45
|
+
],
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/bun": "latest"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"typescript": "^5"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@elysiajs/swagger": "^1.3.1",
|
|
54
|
+
"@sinclair/typebox": "^0.34.48",
|
|
55
|
+
"class-transformer": "^0.5.1",
|
|
56
|
+
"class-validator": "^0.15.1",
|
|
57
|
+
"elysia": "^1.4.27",
|
|
58
|
+
"jose": "^6.2.1",
|
|
59
|
+
"reflect-metadata": "^0.2.2"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { expect, test, describe } from 'bun:test';
|
|
2
|
+
import { PasswordService } from '../password.service';
|
|
3
|
+
|
|
4
|
+
describe('PasswordService', () => {
|
|
5
|
+
const passwordService = new PasswordService();
|
|
6
|
+
|
|
7
|
+
test('should hash and verify passwords correctly using default (bcrypt)', async () => {
|
|
8
|
+
const rawPassword = 'super-secret-password';
|
|
9
|
+
|
|
10
|
+
// Hash
|
|
11
|
+
const hash = await passwordService.hash(rawPassword);
|
|
12
|
+
|
|
13
|
+
expect(hash).toBeString();
|
|
14
|
+
expect(hash).not.toEqual(rawPassword);
|
|
15
|
+
|
|
16
|
+
// Verify success
|
|
17
|
+
const isMatch = await passwordService.verify(rawPassword, hash);
|
|
18
|
+
expect(isMatch).toBe(true);
|
|
19
|
+
|
|
20
|
+
// Verify fail
|
|
21
|
+
const isNotMatch = await passwordService.verify('wrong-password', hash);
|
|
22
|
+
expect(isNotMatch).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('should hash and verify passwords using argon2', async () => {
|
|
26
|
+
const rawPassword = 'another-password-123';
|
|
27
|
+
|
|
28
|
+
// Hash
|
|
29
|
+
const hash = await passwordService.hash(rawPassword, { algorithm: 'argon2id' });
|
|
30
|
+
|
|
31
|
+
expect(hash).toBeString();
|
|
32
|
+
expect(hash.startsWith('$argon2')).toBe(true);
|
|
33
|
+
|
|
34
|
+
// Verify success
|
|
35
|
+
const isMatch = await passwordService.verify(rawPassword, hash);
|
|
36
|
+
expect(isMatch).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Injectable } from '../di/injectable.decorator';
|
|
2
|
+
import { type CanActivate } from '../interfaces';
|
|
3
|
+
import { type Context } from 'elysia';
|
|
4
|
+
import { JwtService } from './jwt.service';
|
|
5
|
+
import { UnauthorizedException } from '../exceptions';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class AuthGuard implements CanActivate {
|
|
9
|
+
constructor(private readonly jwtService: JwtService) {}
|
|
10
|
+
|
|
11
|
+
async canActivate(context: Context): Promise<boolean> {
|
|
12
|
+
const request = context.request;
|
|
13
|
+
const authHeader = request.headers.get('authorization');
|
|
14
|
+
|
|
15
|
+
if (!authHeader) {
|
|
16
|
+
throw new UnauthorizedException('Missing Authorization header');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const [type, token] = authHeader.split(' ');
|
|
20
|
+
|
|
21
|
+
if (type !== 'Bearer' || !token) {
|
|
22
|
+
throw new UnauthorizedException('Invalid token format (expected Bearer <token>)');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const payload = await this.jwtService.verifyAsync(token);
|
|
27
|
+
// Append user payload directly into the context stream so controllers can access it
|
|
28
|
+
(context as any).user = payload;
|
|
29
|
+
return true;
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new UnauthorizedException('Token is invalid or expired');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Module } from '../decorators/module.decorator';
|
|
2
|
+
import { type DynamicModule } from '../interfaces';
|
|
3
|
+
import { JwtService, type JwtModuleOptions } from './jwt.service';
|
|
4
|
+
|
|
5
|
+
@Module({})
|
|
6
|
+
export class JwtModule {
|
|
7
|
+
/**
|
|
8
|
+
* Bootstraps the JwtModule.
|
|
9
|
+
*
|
|
10
|
+
* @param options Configuration options including signing secret and defaults
|
|
11
|
+
*/
|
|
12
|
+
static register(options: JwtModuleOptions): DynamicModule {
|
|
13
|
+
return {
|
|
14
|
+
module: JwtModule,
|
|
15
|
+
providers: [
|
|
16
|
+
{
|
|
17
|
+
provide: JwtService,
|
|
18
|
+
useFactory: () => new JwtService(options),
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
exports: [JwtService]
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Injectable } from '../di/injectable.decorator';
|
|
2
|
+
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
|
|
3
|
+
|
|
4
|
+
export interface JwtModuleOptions {
|
|
5
|
+
secret: string;
|
|
6
|
+
signOptions?: {
|
|
7
|
+
expiresIn?: string | number; // e.g. '1h', '2 days', 3600
|
|
8
|
+
issuer?: string;
|
|
9
|
+
audience?: string | string[];
|
|
10
|
+
algorithm?: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@Injectable()
|
|
15
|
+
export class JwtService {
|
|
16
|
+
private readonly secretKey: Uint8Array;
|
|
17
|
+
|
|
18
|
+
constructor(private readonly options: JwtModuleOptions) {
|
|
19
|
+
if (!options.secret) {
|
|
20
|
+
throw new Error('JwtModule requires a secret key for signing tokens.');
|
|
21
|
+
}
|
|
22
|
+
this.secretKey = new TextEncoder().encode(options.secret);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Synchronously signs a token payload.
|
|
27
|
+
* Note: While jose generally operates async, we provide a signature abstraction
|
|
28
|
+
* that awaits natively inside modern Promise contexts.
|
|
29
|
+
*/
|
|
30
|
+
async signAsync(payload: JWTPayload, options?: JwtModuleOptions['signOptions']): Promise<string> {
|
|
31
|
+
const combinedOptions = { ...this.options.signOptions, ...options };
|
|
32
|
+
const alg = combinedOptions.algorithm || 'HS256';
|
|
33
|
+
|
|
34
|
+
let signer = new SignJWT(payload)
|
|
35
|
+
.setProtectedHeader({ alg })
|
|
36
|
+
.setIssuedAt();
|
|
37
|
+
|
|
38
|
+
if (combinedOptions.expiresIn) {
|
|
39
|
+
signer = signer.setExpirationTime(combinedOptions.expiresIn as any);
|
|
40
|
+
}
|
|
41
|
+
if (combinedOptions.issuer) {
|
|
42
|
+
signer = signer.setIssuer(combinedOptions.issuer);
|
|
43
|
+
}
|
|
44
|
+
if (combinedOptions.audience) {
|
|
45
|
+
signer = signer.setAudience(combinedOptions.audience);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return await signer.sign(this.secretKey);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Verifies a JWT token asynchronously and returns the decoded payload.
|
|
53
|
+
*/
|
|
54
|
+
async verifyAsync<T extends JWTPayload = JWTPayload>(token: string): Promise<T> {
|
|
55
|
+
try {
|
|
56
|
+
const { payload } = await jwtVerify(token, this.secretKey, {
|
|
57
|
+
issuer: this.options.signOptions?.issuer,
|
|
58
|
+
audience: this.options.signOptions?.audience,
|
|
59
|
+
});
|
|
60
|
+
return payload as T;
|
|
61
|
+
} catch (e: any) {
|
|
62
|
+
throw new Error(`Invalid or expired token: ${e.message}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Injectable } from '../di/injectable.decorator';
|
|
2
|
+
|
|
3
|
+
export interface HashOptions {
|
|
4
|
+
/**
|
|
5
|
+
* The hashing algorithm to use.
|
|
6
|
+
* Bun natively supports 'bcrypt', 'argon2id', 'argon2d', 'argon2i'.
|
|
7
|
+
* @default 'bcrypt'
|
|
8
|
+
*/
|
|
9
|
+
algorithm?: 'bcrypt' | 'argon2id' | 'argon2d' | 'argon2i';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* For bcrypt: the cost factor (4-31). Default is 10.
|
|
13
|
+
* For argon2: memory cost, time cost, etc. are handled securely by Bun.
|
|
14
|
+
*/
|
|
15
|
+
cost?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@Injectable()
|
|
19
|
+
export class PasswordService {
|
|
20
|
+
/**
|
|
21
|
+
* Hashes a plaintext password securely.
|
|
22
|
+
* Uses Bun's native, highly optimized `Bun.password.hash` engine.
|
|
23
|
+
*
|
|
24
|
+
* @param password The raw, plaintext password.
|
|
25
|
+
* @param options Hashing algorithm options (default: bcrypt, cost: 10).
|
|
26
|
+
* @returns A Promise that resolves to the hashed password string.
|
|
27
|
+
*/
|
|
28
|
+
async hash(password: string, options?: HashOptions): Promise<string> {
|
|
29
|
+
const alg = options?.algorithm || 'bcrypt';
|
|
30
|
+
const cost = options?.cost || 10;
|
|
31
|
+
|
|
32
|
+
return Bun.password.hash(password, {
|
|
33
|
+
algorithm: alg,
|
|
34
|
+
cost,
|
|
35
|
+
} as any);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Verifies a plaintext password against a previously hashed password string.
|
|
40
|
+
*
|
|
41
|
+
* @param password The raw, plaintext password to check.
|
|
42
|
+
* @param hash The stored hash string (argon2 or bcrypt).
|
|
43
|
+
* @returns A Promise that resolves to true if the password matches the hash.
|
|
44
|
+
*/
|
|
45
|
+
async verify(password: string, hash: string): Promise<boolean> {
|
|
46
|
+
return Bun.password.verify(password, hash);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Module } from '../decorators/module.decorator';
|
|
2
|
+
import { type DynamicModule } from '../interfaces';
|
|
3
|
+
import { ConfigService, type ConfigModuleOptions } from './config.service';
|
|
4
|
+
|
|
5
|
+
@Module({})
|
|
6
|
+
export class ConfigModule {
|
|
7
|
+
/**
|
|
8
|
+
* Bootstraps the ConfigModule.
|
|
9
|
+
*
|
|
10
|
+
* @param options Configuration options including TypeBox schema
|
|
11
|
+
*/
|
|
12
|
+
static forRoot(options: ConfigModuleOptions = {}): DynamicModule {
|
|
13
|
+
return {
|
|
14
|
+
module: ConfigModule,
|
|
15
|
+
providers: [
|
|
16
|
+
{
|
|
17
|
+
provide: ConfigService,
|
|
18
|
+
useFactory: () => new ConfigService(options),
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
exports: [ConfigService]
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Injectable } from '../di/injectable.decorator';
|
|
2
|
+
import { Type as T, type TSchema } from '@sinclair/typebox';
|
|
3
|
+
import { Value } from '@sinclair/typebox/value';
|
|
4
|
+
import { Logger } from '../services/logger.service';
|
|
5
|
+
|
|
6
|
+
export interface ConfigModuleOptions {
|
|
7
|
+
/**
|
|
8
|
+
* TypeBox schema to validate process.env
|
|
9
|
+
*/
|
|
10
|
+
schema?: TSchema;
|
|
11
|
+
/**
|
|
12
|
+
* Optional custom config object. If not provided, process.env is used.
|
|
13
|
+
*/
|
|
14
|
+
load?: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Injectable()
|
|
18
|
+
export class ConfigService<Config = Record<string, string>> {
|
|
19
|
+
private config: Config;
|
|
20
|
+
private readonly logger = new Logger('ConfigService');
|
|
21
|
+
|
|
22
|
+
constructor(options?: ConfigModuleOptions) {
|
|
23
|
+
const rawConfig = options?.load || process.env;
|
|
24
|
+
|
|
25
|
+
if (options?.schema) {
|
|
26
|
+
try {
|
|
27
|
+
const copy = { ...rawConfig };
|
|
28
|
+
|
|
29
|
+
// Convert strings to correct primitives (e.g "3000" -> 3000, "true" -> true)
|
|
30
|
+
Value.Convert(options.schema, copy);
|
|
31
|
+
|
|
32
|
+
// Apply default values defined in the schema
|
|
33
|
+
const defaulted = Value.Default(options.schema, copy) as any;
|
|
34
|
+
|
|
35
|
+
// Validate and decode
|
|
36
|
+
const decoded = Value.Decode(options.schema, defaulted);
|
|
37
|
+
|
|
38
|
+
this.config = (decoded || defaulted) as Config;
|
|
39
|
+
} catch (error: any) {
|
|
40
|
+
this.logger.error('Environment validation failed!');
|
|
41
|
+
|
|
42
|
+
// Print detailed TypeBox errors
|
|
43
|
+
const errors = [...Value.Errors(options.schema, rawConfig)];
|
|
44
|
+
errors.forEach(err => {
|
|
45
|
+
this.logger.error(`- Property '${err.path}' ${err.message}`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
throw new Error('Config validation error');
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
this.config = rawConfig as unknown as Config;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get a configuration value by key
|
|
57
|
+
*/
|
|
58
|
+
get<T = string>(key: keyof Config): T | undefined;
|
|
59
|
+
get<T = string>(key: keyof Config, defaultValue: T): T;
|
|
60
|
+
get<T = string>(key: keyof Config, defaultValue?: T): T | undefined {
|
|
61
|
+
const value = this.config[key];
|
|
62
|
+
if (value !== undefined) {
|
|
63
|
+
return value as unknown as T;
|
|
64
|
+
}
|
|
65
|
+
return defaultValue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get a configuration value asserting it exists
|
|
70
|
+
*/
|
|
71
|
+
getOrThrow<T = string>(key: keyof Config): T {
|
|
72
|
+
const value = this.config[key];
|
|
73
|
+
if (value === undefined) {
|
|
74
|
+
throw new Error(`Configuration key "${String(key)}" does not exist`);
|
|
75
|
+
}
|
|
76
|
+
return value as unknown as T;
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const CONTROLLER_WATERMARK = '__controller__';
|
|
2
|
+
export const INJECTABLE_WATERMARK = '__injectable__';
|
|
3
|
+
export const METHOD_METADATA = 'method';
|
|
4
|
+
export const PATH_METADATA = 'path';
|
|
5
|
+
export const ROUTE_ARGS_METADATA = '__routeArguments__';
|
|
6
|
+
export const GUARDS_METADATA = '__guards__';
|
|
7
|
+
export const INTERCEPTORS_METADATA = '__interceptors__';
|
|
8
|
+
export const PIPES_METADATA = '__pipes__';
|
|
9
|
+
export const FILTERS_METADATA = 'filters';
|
|
10
|
+
export const SCHEMA_METADATA = '__schema__';
|
|
11
|
+
export const MODULE_METADATA = {
|
|
12
|
+
IMPORTS: 'imports',
|
|
13
|
+
PROVIDERS: 'providers',
|
|
14
|
+
CONTROLLERS: 'controllers',
|
|
15
|
+
EXPORTS: 'exports',
|
|
16
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { Type } from '../di/provider';
|
|
3
|
+
|
|
4
|
+
export const FILTER_CATCH_EXCEPTIONS = 'FILTER_CATCH_EXCEPTIONS';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Decorator that marks a class as a Nest exception filter.
|
|
8
|
+
* An exception filter handles thrown exceptions caught during execution.
|
|
9
|
+
*
|
|
10
|
+
* @param exceptions One or more exception types that this filter should catch.
|
|
11
|
+
*/
|
|
12
|
+
export function Catch(...exceptions: Type<any>[]): ClassDecorator {
|
|
13
|
+
return (target: Function) => {
|
|
14
|
+
Reflect.defineMetadata(FILTER_CATCH_EXCEPTIONS, exceptions, target);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { Injectable } from '../di/injectable.decorator';
|
|
3
|
+
|
|
4
|
+
import { CONTROLLER_WATERMARK, PATH_METADATA } from '../constants';
|
|
5
|
+
|
|
6
|
+
export function Controller(prefix: string = ''): ClassDecorator {
|
|
7
|
+
return (target: Function) => {
|
|
8
|
+
Injectable()(target);
|
|
9
|
+
Reflect.defineMetadata(CONTROLLER_WATERMARK, true, target);
|
|
10
|
+
// Ensure prefixes start with / and don't end with / unless it's just /
|
|
11
|
+
const normalizedPrefix = prefix.startsWith('/') ? prefix : `/${prefix}`;
|
|
12
|
+
Reflect.defineMetadata(PATH_METADATA, normalizedPrefix, target);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { FILTERS_METADATA } from '../constants';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Decorator that binds exception filters to the scope of the controller or method.
|
|
6
|
+
*
|
|
7
|
+
* @param filters One or more filter instances or classes to use.
|
|
8
|
+
*/
|
|
9
|
+
export function UseFilters(...filters: (Function | Record<string, any>)[]): MethodDecorator & ClassDecorator {
|
|
10
|
+
return (
|
|
11
|
+
target: any,
|
|
12
|
+
propertyKey?: string | symbol,
|
|
13
|
+
descriptor?: TypedPropertyDescriptor<any>,
|
|
14
|
+
) => {
|
|
15
|
+
if (descriptor) {
|
|
16
|
+
Reflect.defineMetadata(FILTERS_METADATA, filters, descriptor.value!);
|
|
17
|
+
return descriptor;
|
|
18
|
+
}
|
|
19
|
+
Reflect.defineMetadata(FILTERS_METADATA, filters, target);
|
|
20
|
+
return target;
|
|
21
|
+
};
|
|
22
|
+
}
|