@onebun/core 0.2.6 → 0.2.8
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/package.json +6 -6
- package/src/application/application.test.ts +350 -7
- package/src/application/application.ts +537 -254
- package/src/application/multi-service-application.test.ts +15 -0
- package/src/application/multi-service-application.ts +2 -0
- package/src/application/multi-service.types.ts +7 -1
- package/src/decorators/decorators.ts +213 -0
- package/src/docs-examples.test.ts +386 -3
- package/src/exception-filters/exception-filters.test.ts +172 -0
- package/src/exception-filters/exception-filters.ts +129 -0
- package/src/exception-filters/http-exception.ts +22 -0
- package/src/exception-filters/index.ts +2 -0
- package/src/file/onebun-file.ts +8 -2
- package/src/http-guards/http-guards.test.ts +230 -0
- package/src/http-guards/http-guards.ts +173 -0
- package/src/http-guards/index.ts +1 -0
- package/src/index.ts +10 -0
- package/src/module/module.test.ts +78 -0
- package/src/module/module.ts +55 -7
- package/src/queue/docs-examples.test.ts +72 -12
- package/src/queue/index.ts +4 -0
- package/src/queue/queue-service-proxy.test.ts +82 -0
- package/src/queue/queue-service-proxy.ts +114 -0
- package/src/queue/types.ts +2 -2
- package/src/security/cors-middleware.ts +212 -0
- package/src/security/index.ts +19 -0
- package/src/security/rate-limit-middleware.ts +276 -0
- package/src/security/security-headers-middleware.ts +188 -0
- package/src/security/security.test.ts +285 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/testing-module.test.ts +199 -0
- package/src/testing/testing-module.ts +252 -0
- package/src/types.ts +153 -3
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exception Filters
|
|
3
|
+
*
|
|
4
|
+
* Intercept and transform errors thrown by route handlers.
|
|
5
|
+
* Apply with `@UseFilters()` on controllers or individual routes,
|
|
6
|
+
* or globally via `ApplicationOptions.filters`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { HttpExecutionContext, OneBunResponse } from '../types';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createErrorResponse,
|
|
13
|
+
HttpStatusCode,
|
|
14
|
+
OneBunBaseError,
|
|
15
|
+
} from '@onebun/requests';
|
|
16
|
+
|
|
17
|
+
import { HttpException } from './http-exception';
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Interfaces
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Exception Filter interface — implement to handle errors thrown by route handlers.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* class HttpExceptionFilter implements ExceptionFilter {
|
|
29
|
+
* catch(error: unknown, ctx: HttpExecutionContext): Response {
|
|
30
|
+
* const status = error instanceof OneBunBaseError ? error.code : 500;
|
|
31
|
+
* return new Response(JSON.stringify({ message: String(error) }), {
|
|
32
|
+
* status,
|
|
33
|
+
* headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
* });
|
|
35
|
+
* }
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export interface ExceptionFilter {
|
|
40
|
+
catch(error: unknown, context: HttpExecutionContext): OneBunResponse | Promise<OneBunResponse>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Factory
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create a custom exception filter from a plain function.
|
|
49
|
+
*
|
|
50
|
+
* @param fn - Filter function receiving the error and execution context
|
|
51
|
+
* @returns An ExceptionFilter instance
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```typescript
|
|
55
|
+
* const logAndForwardFilter = createExceptionFilter((error, ctx) => {
|
|
56
|
+
* console.error(`[${ctx.getController()}#${ctx.getHandler()}]`, error);
|
|
57
|
+
* return new Response('Internal Error', { status: 500 });
|
|
58
|
+
* });
|
|
59
|
+
*
|
|
60
|
+
* @UseFilters(logAndForwardFilter)
|
|
61
|
+
* @Get('/risky')
|
|
62
|
+
* riskyRoute() { ... }
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function createExceptionFilter(
|
|
66
|
+
fn: (error: unknown, context: HttpExecutionContext) => OneBunResponse | Promise<OneBunResponse>,
|
|
67
|
+
): ExceptionFilter {
|
|
68
|
+
return { catch: fn };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// Default filter (wraps existing error-handling logic)
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Default exception filter — mirrors the built-in error handling behaviour.
|
|
77
|
+
* - `OneBunBaseError` instances are serialised with their own `toErrorResponse()`.
|
|
78
|
+
* - Every other error is converted to a generic 500 response.
|
|
79
|
+
* All responses use HTTP 200 with the standardised `ApiResponse` envelope,
|
|
80
|
+
* consistent with the rest of the framework.
|
|
81
|
+
*/
|
|
82
|
+
export const defaultExceptionFilter: ExceptionFilter = {
|
|
83
|
+
catch(error: unknown): OneBunResponse {
|
|
84
|
+
if (error instanceof HttpException) {
|
|
85
|
+
const errorResponse = createErrorResponse(
|
|
86
|
+
error.message,
|
|
87
|
+
error.statusCode,
|
|
88
|
+
error.message,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
return new Response(JSON.stringify(errorResponse), {
|
|
92
|
+
status: error.statusCode,
|
|
93
|
+
headers: {
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (error instanceof OneBunBaseError) {
|
|
101
|
+
return new Response(JSON.stringify(error.toErrorResponse()), {
|
|
102
|
+
status: HttpStatusCode.OK,
|
|
103
|
+
headers: {
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
105
|
+
'Content-Type': 'application/json',
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
111
|
+
const code =
|
|
112
|
+
error instanceof Error && 'code' in error
|
|
113
|
+
? Number((error as { code: unknown }).code)
|
|
114
|
+
: HttpStatusCode.INTERNAL_SERVER_ERROR;
|
|
115
|
+
|
|
116
|
+
const errorResponse = createErrorResponse(message, code, message, undefined, {
|
|
117
|
+
originalErrorName: error instanceof Error ? error.name : 'UnknownError',
|
|
118
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return new Response(JSON.stringify(errorResponse), {
|
|
122
|
+
status: HttpStatusCode.OK,
|
|
123
|
+
headers: {
|
|
124
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
125
|
+
'Content-Type': 'application/json',
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP exception that carries a status code.
|
|
3
|
+
*
|
|
4
|
+
* Throw from route handlers, guards, or middleware to return
|
|
5
|
+
* a specific HTTP status. The default exception filter converts
|
|
6
|
+
* these into JSON responses with the matching status code.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* throw new HttpException(404, 'User not found');
|
|
11
|
+
* // → HTTP 404 { success: false, error: "User not found", ... }
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export class HttpException extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
public readonly statusCode: number,
|
|
17
|
+
message: string,
|
|
18
|
+
) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'HttpException';
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/file/onebun-file.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { HttpStatusCode } from '@onebun/requests';
|
|
2
|
+
|
|
3
|
+
import { HttpException } from '../exception-filters/http-exception';
|
|
4
|
+
|
|
1
5
|
/**
|
|
2
6
|
* OneBunFile - Unified file wrapper for file uploads
|
|
3
7
|
*
|
|
@@ -287,7 +291,8 @@ export function validateFile(
|
|
|
287
291
|
|
|
288
292
|
// Validate file size
|
|
289
293
|
if (options.maxSize !== undefined && file.size > options.maxSize) {
|
|
290
|
-
throw new
|
|
294
|
+
throw new HttpException(
|
|
295
|
+
HttpStatusCode.BAD_REQUEST,
|
|
291
296
|
`${prefix} exceeds maximum size. Got ${file.size} bytes, max is ${options.maxSize} bytes`,
|
|
292
297
|
);
|
|
293
298
|
}
|
|
@@ -296,7 +301,8 @@ export function validateFile(
|
|
|
296
301
|
if (options.mimeTypes && options.mimeTypes.length > 0) {
|
|
297
302
|
const matches = options.mimeTypes.some((pattern) => matchMimeType(file.type, pattern));
|
|
298
303
|
if (!matches) {
|
|
299
|
-
throw new
|
|
304
|
+
throw new HttpException(
|
|
305
|
+
HttpStatusCode.BAD_REQUEST,
|
|
300
306
|
`${prefix} has invalid MIME type "${file.type}". Allowed: ${options.mimeTypes.join(', ')}`,
|
|
301
307
|
);
|
|
302
308
|
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
expect,
|
|
4
|
+
it,
|
|
5
|
+
} from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
HttpExecutionContext,
|
|
9
|
+
HttpGuard,
|
|
10
|
+
OneBunRequest,
|
|
11
|
+
} from '../types';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
AuthGuard,
|
|
15
|
+
createHttpGuard,
|
|
16
|
+
executeHttpGuards,
|
|
17
|
+
HttpExecutionContextImpl,
|
|
18
|
+
RolesGuard,
|
|
19
|
+
} from './http-guards';
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Helpers
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
function makeRequest(headers?: Headers): OneBunRequest {
|
|
26
|
+
return new Request('http://localhost/test', { headers }) as unknown as OneBunRequest;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeHeaders(entries: [string, string][]): Headers {
|
|
30
|
+
const h = new Headers();
|
|
31
|
+
for (const [key, value] of entries) {
|
|
32
|
+
h.set(key, value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return h;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeContext(
|
|
39
|
+
headers: [string, string][] = [],
|
|
40
|
+
handler = 'testHandler',
|
|
41
|
+
controller = 'TestController',
|
|
42
|
+
): HttpExecutionContext {
|
|
43
|
+
return new HttpExecutionContextImpl(makeRequest(makeHeaders(headers)), handler, controller);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// HttpExecutionContextImpl
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
describe('HttpExecutionContextImpl', () => {
|
|
51
|
+
it('returns request from getRequest()', () => {
|
|
52
|
+
const req = makeRequest(makeHeaders([['authorization', 'Bearer token']]));
|
|
53
|
+
const ctx = new HttpExecutionContextImpl(req, 'myHandler', 'MyController');
|
|
54
|
+
|
|
55
|
+
expect(ctx.getRequest()).toBe(req);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns handler name from getHandler()', () => {
|
|
59
|
+
const ctx = makeContext([], 'getUser', 'UserController');
|
|
60
|
+
|
|
61
|
+
expect(ctx.getHandler()).toBe('getUser');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns controller name from getController()', () => {
|
|
65
|
+
const ctx = makeContext([], 'getUser', 'UserController');
|
|
66
|
+
|
|
67
|
+
expect(ctx.getController()).toBe('UserController');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// ============================================================================
|
|
72
|
+
// executeHttpGuards
|
|
73
|
+
// ============================================================================
|
|
74
|
+
|
|
75
|
+
describe('executeHttpGuards', () => {
|
|
76
|
+
it('returns true when there are no guards', async () => {
|
|
77
|
+
const ctx = makeContext();
|
|
78
|
+
|
|
79
|
+
expect(await executeHttpGuards([], ctx)).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('returns true when all guards pass', async () => {
|
|
83
|
+
const passGuard = createHttpGuard(() => true);
|
|
84
|
+
const ctx = makeContext();
|
|
85
|
+
|
|
86
|
+
expect(await executeHttpGuards([passGuard, passGuard], ctx)).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns false when any guard fails', async () => {
|
|
90
|
+
const passGuard = createHttpGuard(() => true);
|
|
91
|
+
const failGuard = createHttpGuard(() => false);
|
|
92
|
+
const ctx = makeContext();
|
|
93
|
+
|
|
94
|
+
expect(await executeHttpGuards([passGuard, failGuard, passGuard], ctx)).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('short-circuits on first failing guard', async () => {
|
|
98
|
+
let secondCalled = false;
|
|
99
|
+
|
|
100
|
+
const failGuard = createHttpGuard(() => false);
|
|
101
|
+
const trackGuard = createHttpGuard(() => {
|
|
102
|
+
secondCalled = true;
|
|
103
|
+
|
|
104
|
+
return true;
|
|
105
|
+
});
|
|
106
|
+
const ctx = makeContext();
|
|
107
|
+
|
|
108
|
+
await executeHttpGuards([failGuard, trackGuard], ctx);
|
|
109
|
+
|
|
110
|
+
expect(secondCalled).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('accepts guard instances (not just class constructors)', async () => {
|
|
114
|
+
const instance: HttpGuard = { canActivate: () => true };
|
|
115
|
+
const ctx = makeContext();
|
|
116
|
+
|
|
117
|
+
expect(await executeHttpGuards([instance], ctx)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('accepts async guards', async () => {
|
|
121
|
+
const asyncPassGuard = createHttpGuard(async () => {
|
|
122
|
+
await Promise.resolve();
|
|
123
|
+
|
|
124
|
+
return true;
|
|
125
|
+
});
|
|
126
|
+
const ctx = makeContext();
|
|
127
|
+
|
|
128
|
+
expect(await executeHttpGuards([asyncPassGuard], ctx)).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ============================================================================
|
|
133
|
+
// createHttpGuard
|
|
134
|
+
// ============================================================================
|
|
135
|
+
|
|
136
|
+
describe('createHttpGuard', () => {
|
|
137
|
+
it('returns a class constructor', () => {
|
|
138
|
+
const guardClass = createHttpGuard(() => true);
|
|
139
|
+
|
|
140
|
+
expect(typeof guardClass).toBe('function');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('instantiated class calls the provided function', async () => {
|
|
144
|
+
let called = false;
|
|
145
|
+
const guardClass = createHttpGuard((ctx) => {
|
|
146
|
+
called = true;
|
|
147
|
+
|
|
148
|
+
return ctx.getHandler() === 'target';
|
|
149
|
+
});
|
|
150
|
+
const ctx = makeContext([], 'target');
|
|
151
|
+
const instance = new guardClass();
|
|
152
|
+
|
|
153
|
+
expect(await instance.canActivate(ctx)).toBe(true);
|
|
154
|
+
expect(called).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ============================================================================
|
|
159
|
+
// AuthGuard
|
|
160
|
+
// ============================================================================
|
|
161
|
+
|
|
162
|
+
describe('AuthGuard', () => {
|
|
163
|
+
it('allows request with Bearer token', () => {
|
|
164
|
+
const guard = new AuthGuard();
|
|
165
|
+
const ctx = makeContext([['authorization', 'Bearer my-token']]);
|
|
166
|
+
|
|
167
|
+
expect(guard.canActivate(ctx)).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('blocks request without Authorization header', () => {
|
|
171
|
+
const guard = new AuthGuard();
|
|
172
|
+
const ctx = makeContext();
|
|
173
|
+
|
|
174
|
+
expect(guard.canActivate(ctx)).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('blocks request with non-Bearer Authorization header', () => {
|
|
178
|
+
const guard = new AuthGuard();
|
|
179
|
+
const ctx = makeContext([['authorization', 'Basic dXNlcjpwYXNz']]);
|
|
180
|
+
|
|
181
|
+
expect(guard.canActivate(ctx)).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ============================================================================
|
|
186
|
+
// RolesGuard
|
|
187
|
+
// ============================================================================
|
|
188
|
+
|
|
189
|
+
describe('RolesGuard', () => {
|
|
190
|
+
it('allows when user has all required roles (default extractor)', () => {
|
|
191
|
+
const guard = new RolesGuard(['admin', 'editor']);
|
|
192
|
+
const headers = makeHeaders([['x-user-roles', 'admin, editor, viewer']]);
|
|
193
|
+
const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
|
|
194
|
+
|
|
195
|
+
expect(guard.canActivate(ctx)).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('blocks when user is missing a required role', () => {
|
|
199
|
+
const guard = new RolesGuard(['admin']);
|
|
200
|
+
const headers = makeHeaders([['x-user-roles', 'viewer']]);
|
|
201
|
+
const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
|
|
202
|
+
|
|
203
|
+
expect(guard.canActivate(ctx)).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('blocks when x-user-roles header is absent', () => {
|
|
207
|
+
const guard = new RolesGuard(['admin']);
|
|
208
|
+
const ctx = makeContext();
|
|
209
|
+
|
|
210
|
+
expect(guard.canActivate(ctx)).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('uses custom roles extractor when provided', () => {
|
|
214
|
+
const headers = makeHeaders([['x-roles', 'admin|user']]);
|
|
215
|
+
const guard = new RolesGuard(['admin'], (ctx) =>
|
|
216
|
+
ctx.getRequest().headers.get('x-roles')?.split('|') ?? [],
|
|
217
|
+
);
|
|
218
|
+
const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
|
|
219
|
+
|
|
220
|
+
expect(guard.canActivate(ctx)).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('requires ALL roles to be present (not just one)', () => {
|
|
224
|
+
const guard = new RolesGuard(['admin', 'superuser']);
|
|
225
|
+
const headers = makeHeaders([['x-user-roles', 'admin']]);
|
|
226
|
+
const ctx = new HttpExecutionContextImpl(makeRequest(headers), 'handler', 'Controller');
|
|
227
|
+
|
|
228
|
+
expect(guard.canActivate(ctx)).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Guards
|
|
3
|
+
*
|
|
4
|
+
* Guards for authorizing HTTP requests before they reach the route handler.
|
|
5
|
+
* Apply with `@UseGuards()` on controllers or individual routes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
HttpExecutionContext,
|
|
10
|
+
HttpGuard,
|
|
11
|
+
OneBunRequest,
|
|
12
|
+
} from '../types';
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Execution Context Implementation
|
|
16
|
+
// ============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Implementation of HttpExecutionContext
|
|
20
|
+
*/
|
|
21
|
+
export class HttpExecutionContextImpl implements HttpExecutionContext {
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly request: OneBunRequest,
|
|
24
|
+
private readonly handlerName: string,
|
|
25
|
+
private readonly controllerName: string,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
getRequest(): OneBunRequest {
|
|
29
|
+
return this.request;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getHandler(): string {
|
|
33
|
+
return this.handlerName;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getController(): string {
|
|
37
|
+
return this.controllerName;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// Guard Execution Helper
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Execute a list of HTTP guards sequentially.
|
|
47
|
+
* Returns false as soon as any guard denies access (short-circuit).
|
|
48
|
+
*
|
|
49
|
+
* @param guards - Array of guard class constructors or instances
|
|
50
|
+
* @param context - Execution context for this request
|
|
51
|
+
* @returns Whether all guards passed
|
|
52
|
+
*/
|
|
53
|
+
export async function executeHttpGuards(
|
|
54
|
+
guards: (Function | HttpGuard)[],
|
|
55
|
+
context: HttpExecutionContext,
|
|
56
|
+
): Promise<boolean> {
|
|
57
|
+
for (const guard of guards) {
|
|
58
|
+
let guardInstance: HttpGuard;
|
|
59
|
+
|
|
60
|
+
if (typeof guard === 'function') {
|
|
61
|
+
guardInstance = new (guard as new () => HttpGuard)();
|
|
62
|
+
} else {
|
|
63
|
+
guardInstance = guard;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const result = await guardInstance.canActivate(context);
|
|
67
|
+
if (!result) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a custom HTTP guard from a plain function.
|
|
77
|
+
* Returns a class constructor compatible with `@UseGuards()`.
|
|
78
|
+
*
|
|
79
|
+
* @param fn - Guard function receiving the execution context
|
|
80
|
+
* @returns Guard class constructor
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```typescript
|
|
84
|
+
* const ApiKeyGuard = createHttpGuard((ctx) => {
|
|
85
|
+
* return ctx.getRequest().headers.get('x-api-key') === process.env.API_KEY;
|
|
86
|
+
* });
|
|
87
|
+
*
|
|
88
|
+
* @UseGuards(ApiKeyGuard)
|
|
89
|
+
* @Get('/protected')
|
|
90
|
+
* getData() { ... }
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export function createHttpGuard(
|
|
94
|
+
fn: (context: HttpExecutionContext) => boolean | Promise<boolean>,
|
|
95
|
+
): new () => HttpGuard {
|
|
96
|
+
return class implements HttpGuard {
|
|
97
|
+
canActivate(context: HttpExecutionContext): boolean | Promise<boolean> {
|
|
98
|
+
return fn(context);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// Built-in Guards
|
|
105
|
+
// ============================================================================
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Guard that requires a valid Bearer token in the Authorization header.
|
|
109
|
+
* Does NOT validate the token — only checks that the header is present.
|
|
110
|
+
* Combine with a custom middleware or guard to validate the token itself.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```typescript
|
|
114
|
+
* @UseGuards(AuthGuard)
|
|
115
|
+
* @Get('/profile')
|
|
116
|
+
* getProfile() { ... }
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
export class AuthGuard implements HttpGuard {
|
|
120
|
+
canActivate(context: HttpExecutionContext): boolean {
|
|
121
|
+
const auth = context.getRequest().headers.get('authorization');
|
|
122
|
+
|
|
123
|
+
return auth !== null && auth.startsWith('Bearer ');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Default roles extractor — reads comma-separated roles from the `x-user-roles` header.
|
|
129
|
+
* Set this header from your auth middleware after validating the token.
|
|
130
|
+
*/
|
|
131
|
+
function defaultRolesExtractor(ctx: HttpExecutionContext): string[] {
|
|
132
|
+
const rolesHeader = ctx.getRequest().headers.get('x-user-roles');
|
|
133
|
+
|
|
134
|
+
return rolesHeader ? rolesHeader.split(',').map((r) => r.trim()) : [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Guard that requires all specified roles to be present on the request.
|
|
139
|
+
* By default reads roles from the `x-user-roles` header (comma-separated).
|
|
140
|
+
* Provide a custom `rolesExtractor` to read roles from a different source
|
|
141
|
+
* (e.g. a JWT claim decoded by a preceding auth middleware).
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```typescript
|
|
145
|
+
* // All specified roles must be present
|
|
146
|
+
* @UseGuards(new RolesGuard(['admin', 'moderator']))
|
|
147
|
+
* @Delete('/users/:id')
|
|
148
|
+
* deleteUser() { ... }
|
|
149
|
+
*
|
|
150
|
+
* // Custom roles extractor
|
|
151
|
+
* @UseGuards(new RolesGuard(['admin'], (ctx) => ctx.getRequest().headers.get('x-roles')?.split('|') ?? []))
|
|
152
|
+
* @Get('/admin')
|
|
153
|
+
* adminPanel() { ... }
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
export class RolesGuard implements HttpGuard {
|
|
157
|
+
private readonly roles: string[];
|
|
158
|
+
private readonly rolesExtractor: (ctx: HttpExecutionContext) => string[];
|
|
159
|
+
|
|
160
|
+
constructor(
|
|
161
|
+
roles: string[],
|
|
162
|
+
rolesExtractor: (ctx: HttpExecutionContext) => string[] = defaultRolesExtractor,
|
|
163
|
+
) {
|
|
164
|
+
this.roles = roles;
|
|
165
|
+
this.rolesExtractor = rolesExtractor;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
canActivate(context: HttpExecutionContext): boolean {
|
|
169
|
+
const userRoles = this.rolesExtractor(context);
|
|
170
|
+
|
|
171
|
+
return this.roles.every((role) => userRoles.includes(role));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './http-guards';
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ export {
|
|
|
48
48
|
type WsStorageType,
|
|
49
49
|
type WsStorageOptions,
|
|
50
50
|
type WebSocketApplicationOptions,
|
|
51
|
+
type StaticApplicationOptions,
|
|
51
52
|
// Docs types
|
|
52
53
|
type DocsApplicationOptions,
|
|
53
54
|
// SSE types
|
|
@@ -131,3 +132,12 @@ export * from './validation';
|
|
|
131
132
|
|
|
132
133
|
// Testing Utilities
|
|
133
134
|
export * from './testing';
|
|
135
|
+
|
|
136
|
+
// HTTP Guards
|
|
137
|
+
export * from './http-guards';
|
|
138
|
+
|
|
139
|
+
// Exception Filters
|
|
140
|
+
export * from './exception-filters';
|
|
141
|
+
|
|
142
|
+
// Security Middleware
|
|
143
|
+
export * from './security';
|
|
@@ -1422,6 +1422,84 @@ describe('OneBunModule', () => {
|
|
|
1422
1422
|
expect(depValueInInit).not.toBeNull();
|
|
1423
1423
|
expect(depValueInInit as unknown as number).toBe(8080);
|
|
1424
1424
|
});
|
|
1425
|
+
|
|
1426
|
+
test('should call onModuleInit for services and controllers in deeply nested module tree', async () => {
|
|
1427
|
+
const initLog: string[] = [];
|
|
1428
|
+
|
|
1429
|
+
@Service()
|
|
1430
|
+
class GrandchildService {
|
|
1431
|
+
async onModuleInit(): Promise<void> {
|
|
1432
|
+
initLog.push('grandchild-service');
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
@CtrlDeco('/grandchild')
|
|
1437
|
+
class GrandchildController extends CtrlBase {
|
|
1438
|
+
async onModuleInit(): Promise<void> {
|
|
1439
|
+
initLog.push('grandchild-controller');
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
@Module({
|
|
1444
|
+
providers: [GrandchildService],
|
|
1445
|
+
controllers: [GrandchildController],
|
|
1446
|
+
})
|
|
1447
|
+
class GrandchildModule {}
|
|
1448
|
+
|
|
1449
|
+
@Service()
|
|
1450
|
+
class ChildService {
|
|
1451
|
+
async onModuleInit(): Promise<void> {
|
|
1452
|
+
initLog.push('child-service');
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
@CtrlDeco('/child')
|
|
1457
|
+
class ChildController extends CtrlBase {
|
|
1458
|
+
async onModuleInit(): Promise<void> {
|
|
1459
|
+
initLog.push('child-controller');
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
@Module({
|
|
1464
|
+
imports: [GrandchildModule],
|
|
1465
|
+
providers: [ChildService],
|
|
1466
|
+
controllers: [ChildController],
|
|
1467
|
+
})
|
|
1468
|
+
class ChildModule {}
|
|
1469
|
+
|
|
1470
|
+
@Service()
|
|
1471
|
+
class RootService {
|
|
1472
|
+
async onModuleInit(): Promise<void> {
|
|
1473
|
+
initLog.push('root-service');
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
@CtrlDeco('/root')
|
|
1478
|
+
class RootController extends CtrlBase {
|
|
1479
|
+
async onModuleInit(): Promise<void> {
|
|
1480
|
+
initLog.push('root-controller');
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
@Module({
|
|
1485
|
+
imports: [ChildModule],
|
|
1486
|
+
providers: [RootService],
|
|
1487
|
+
controllers: [RootController],
|
|
1488
|
+
})
|
|
1489
|
+
class RootModule {}
|
|
1490
|
+
|
|
1491
|
+
const module = new ModuleClass(RootModule, mockLoggerLayer);
|
|
1492
|
+
await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
|
|
1493
|
+
|
|
1494
|
+
// All services and controllers across all levels must have onModuleInit called
|
|
1495
|
+
expect(initLog).toContain('grandchild-service');
|
|
1496
|
+
expect(initLog).toContain('grandchild-controller');
|
|
1497
|
+
expect(initLog).toContain('child-service');
|
|
1498
|
+
expect(initLog).toContain('child-controller');
|
|
1499
|
+
expect(initLog).toContain('root-service');
|
|
1500
|
+
expect(initLog).toContain('root-controller');
|
|
1501
|
+
expect(initLog.length).toBe(6);
|
|
1502
|
+
});
|
|
1425
1503
|
});
|
|
1426
1504
|
|
|
1427
1505
|
describe('Module DI scoping (exports only for cross-module)', () => {
|