@onebun/core 0.2.7 → 0.2.9

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.
@@ -1,11 +1,14 @@
1
1
  import './metadata'; // Import polyfill first
2
2
  import { type, type Type } from 'arktype';
3
3
 
4
+ import type { ExceptionFilter } from '../exception-filters/exception-filters';
5
+
4
6
  import {
5
7
  type ControllerMetadata,
6
8
  type FileUploadOptions,
7
9
  type FilesUploadOptions,
8
10
  HttpMethod,
11
+ type HttpGuard,
9
12
  type ParamDecoratorOptions,
10
13
  type ParamMetadata,
11
14
  ParamType,
@@ -213,6 +216,30 @@ export function controllerDecorator(basePath: string = '') {
213
216
  );
214
217
  }
215
218
 
219
+ // Copy controller-level guards from original class to wrapped class
220
+ // This ensures @UseGuards works regardless of decorator order
221
+ const existingControllerGuards: (Function | HttpGuard)[] | undefined =
222
+ Reflect.getMetadata(HTTP_CONTROLLER_GUARDS_METADATA, target);
223
+ if (existingControllerGuards) {
224
+ Reflect.defineMetadata(
225
+ HTTP_CONTROLLER_GUARDS_METADATA,
226
+ existingControllerGuards,
227
+ WrappedController,
228
+ );
229
+ }
230
+
231
+ // Copy controller-level exception filters from original class to wrapped class
232
+ // This ensures @UseFilters works regardless of decorator order
233
+ const existingControllerFilters: ExceptionFilter[] | undefined =
234
+ Reflect.getMetadata(CONTROLLER_EXCEPTION_FILTERS_METADATA, target);
235
+ if (existingControllerFilters) {
236
+ Reflect.defineMetadata(
237
+ CONTROLLER_EXCEPTION_FILTERS_METADATA,
238
+ existingControllerFilters,
239
+ WrappedController,
240
+ );
241
+ }
242
+
216
243
  return WrappedController as T;
217
244
  };
218
245
  }
@@ -330,6 +357,14 @@ function createRouteDecorator(method: HttpMethod) {
330
357
  const middleware: Function[] =
331
358
  Reflect.getMetadata(MIDDLEWARE_METADATA, target, propertyKey) || [];
332
359
 
360
+ // Get HTTP guards metadata if exists
361
+ const guards: (Function | HttpGuard)[] =
362
+ Reflect.getMetadata(HTTP_GUARDS_METADATA, target, propertyKey) || [];
363
+
364
+ // Get exception filters metadata if exists
365
+ const filters: ExceptionFilter[] =
366
+ Reflect.getMetadata(EXCEPTION_FILTERS_METADATA, target, propertyKey) || [];
367
+
333
368
  // Get response schemas metadata if exists
334
369
  const responseSchemas: Array<{
335
370
  statusCode: number;
@@ -343,6 +378,8 @@ function createRouteDecorator(method: HttpMethod) {
343
378
  handler: propertyKey,
344
379
  params,
345
380
  middleware,
381
+ guards,
382
+ filters,
346
383
  responseSchemas: responseSchemas.map((rs) => ({
347
384
  statusCode: rs.statusCode,
348
385
  schema: rs.schema,
@@ -679,6 +716,26 @@ export function FormField(fieldName: string, options?: ParamDecoratorOptions): P
679
716
  */
680
717
  const CONTROLLER_MIDDLEWARE_METADATA = 'onebun:controller_middleware';
681
718
 
719
+ /**
720
+ * Metadata key for route-level HTTP guards
721
+ */
722
+ const HTTP_GUARDS_METADATA = 'onebun:http_guards';
723
+
724
+ /**
725
+ * Metadata key for controller-level HTTP guards
726
+ */
727
+ const HTTP_CONTROLLER_GUARDS_METADATA = 'onebun:controller_http_guards';
728
+
729
+ /**
730
+ * Metadata key for route-level exception filters
731
+ */
732
+ const EXCEPTION_FILTERS_METADATA = 'onebun:exception_filters';
733
+
734
+ /**
735
+ * Metadata key for controller-level exception filters
736
+ */
737
+ const CONTROLLER_EXCEPTION_FILTERS_METADATA = 'onebun:controller_exception_filters';
738
+
682
739
  /**
683
740
  * Middleware decorator — can be applied to both controllers (class) and individual routes (method).
684
741
  *
@@ -765,6 +822,162 @@ export function getControllerMiddleware(target: Function): Function[] {
765
822
  return Reflect.getMetadata(CONTROLLER_MIDDLEWARE_METADATA, target) || [];
766
823
  }
767
824
 
825
+ /**
826
+ * Get controller-level HTTP guards for a controller class.
827
+ * Returns guards registered via @UseGuards() applied to the class.
828
+ *
829
+ * @param target - Controller class (constructor)
830
+ * @returns Array of guard class constructors or instances
831
+ */
832
+ export function getControllerGuards(target: Function): (Function | HttpGuard)[] {
833
+ return Reflect.getMetadata(HTTP_CONTROLLER_GUARDS_METADATA, target) || [];
834
+ }
835
+
836
+ /**
837
+ * Guards decorator — can be applied to both controllers (class) and individual routes (method).
838
+ *
839
+ * Pass guard **class constructors** or **instances**. Class constructors are instantiated
840
+ * for each request; instances are reused.
841
+ *
842
+ * Guards run after the middleware chain but before the route handler.
843
+ * If any guard returns false, the request is rejected with a 403 Forbidden response.
844
+ *
845
+ * Execution order: middleware → guards → handler
846
+ *
847
+ * @example Class-level (all routes)
848
+ * ```typescript
849
+ * \@Controller('/admin')
850
+ * \@UseGuards(AuthGuard)
851
+ * class AdminController extends BaseController { ... }
852
+ * ```
853
+ *
854
+ * @example Method-level (single route)
855
+ * ```typescript
856
+ * \@Get('/dashboard')
857
+ * \@UseGuards(new RolesGuard(['admin']))
858
+ * getDashboard() { ... }
859
+ * ```
860
+ *
861
+ * @example Combined (class guard + route guard)
862
+ * ```typescript
863
+ * \@Controller('/api')
864
+ * \@UseGuards(AuthGuard) // runs on every route
865
+ * class ApiController extends BaseController {
866
+ * \@Get('/admin')
867
+ * \@UseGuards(new RolesGuard(['admin'])) // runs only here, after AuthGuard
868
+ * getAdmin() { ... }
869
+ * }
870
+ * ```
871
+ */
872
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
873
+ export function UseGuards(...guards: (Function | HttpGuard)[]): any {
874
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
875
+ return function useGuardsDecorator(...args: any[]): any {
876
+ // ---- Class decorator: target is a constructor function ----
877
+ if (args.length === 1 && typeof args[0] === 'function') {
878
+ const target = args[0] as Function;
879
+ const existing: (Function | HttpGuard)[] =
880
+ Reflect.getMetadata(HTTP_CONTROLLER_GUARDS_METADATA, target) || [];
881
+ Reflect.defineMetadata(
882
+ HTTP_CONTROLLER_GUARDS_METADATA,
883
+ [...existing, ...guards],
884
+ target,
885
+ );
886
+
887
+ return target;
888
+ }
889
+
890
+ // ---- Method decorator: (target, propertyKey, descriptor) ----
891
+ const [target, propertyKey, descriptor] = args as [
892
+ object,
893
+ string | symbol,
894
+ PropertyDescriptor,
895
+ ];
896
+ const existingGuards: (Function | HttpGuard)[] =
897
+ Reflect.getMetadata(HTTP_GUARDS_METADATA, target, propertyKey) || [];
898
+ Reflect.defineMetadata(
899
+ HTTP_GUARDS_METADATA,
900
+ [...existingGuards, ...guards],
901
+ target,
902
+ propertyKey,
903
+ );
904
+
905
+ return descriptor;
906
+ };
907
+ }
908
+
909
+ /**
910
+ * Get controller-level exception filters for a controller class.
911
+ * Returns filters registered via @UseFilters() applied to the class.
912
+ *
913
+ * @param target - Controller class (constructor)
914
+ * @returns Array of exception filter instances
915
+ */
916
+ export function getControllerFilters(target: Function): ExceptionFilter[] {
917
+ return Reflect.getMetadata(CONTROLLER_EXCEPTION_FILTERS_METADATA, target) || [];
918
+ }
919
+
920
+ /**
921
+ * Exception Filters decorator — can be applied to both controllers (class) and individual routes (method).
922
+ *
923
+ * Pass filter **instances** (from `createExceptionFilter()` or implementing `ExceptionFilter`).
924
+ * Route-level filters take priority over controller-level filters.
925
+ *
926
+ * Filters run in order: controller-level first, then route-level.
927
+ * The first filter in the combined list catches the error.
928
+ * If no filters are registered, the default filter handles the error.
929
+ *
930
+ * @example Class-level (all routes)
931
+ * ```typescript
932
+ * \@Controller('/api')
933
+ * \@UseFilters(new HttpExceptionFilter())
934
+ * class ApiController extends BaseController { ... }
935
+ * ```
936
+ *
937
+ * @example Method-level (single route)
938
+ * ```typescript
939
+ * \@Get('/risky')
940
+ * \@UseFilters(createExceptionFilter((err, ctx) => new Response('Custom error', { status: 500 })))
941
+ * riskyRoute() { ... }
942
+ * ```
943
+ */
944
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
945
+ export function UseFilters(...filters: ExceptionFilter[]): any {
946
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
947
+ return function useFiltersDecorator(...args: any[]): any {
948
+ // ---- Class decorator: target is a constructor function ----
949
+ if (args.length === 1 && typeof args[0] === 'function') {
950
+ const target = args[0] as Function;
951
+ const existing: ExceptionFilter[] =
952
+ Reflect.getMetadata(CONTROLLER_EXCEPTION_FILTERS_METADATA, target) || [];
953
+ Reflect.defineMetadata(
954
+ CONTROLLER_EXCEPTION_FILTERS_METADATA,
955
+ [...existing, ...filters],
956
+ target,
957
+ );
958
+
959
+ return target;
960
+ }
961
+
962
+ // ---- Method decorator: (target, propertyKey, descriptor) ----
963
+ const [target, propertyKey, descriptor] = args as [
964
+ object,
965
+ string | symbol,
966
+ PropertyDescriptor,
967
+ ];
968
+ const existingFilters: ExceptionFilter[] =
969
+ Reflect.getMetadata(EXCEPTION_FILTERS_METADATA, target, propertyKey) || [];
970
+ Reflect.defineMetadata(
971
+ EXCEPTION_FILTERS_METADATA,
972
+ [...existingFilters, ...filters],
973
+ target,
974
+ propertyKey,
975
+ );
976
+
977
+ return descriptor;
978
+ };
979
+ }
980
+
768
981
  /**
769
982
  * HTTP GET decorator
770
983
  */
@@ -9,6 +9,9 @@
9
9
  * - docs/api/services.md
10
10
  * - docs/api/validation.md
11
11
  * - docs/api/websocket.md
12
+ * - docs/api/guards.md
13
+ * - docs/api/exception-filters.md
14
+ * - docs/api/security.md
12
15
  * - docs/examples/basic-app.md
13
16
  * - docs/examples/crud-api.md
14
17
  * - docs/examples/websocket-chat.md
@@ -33,6 +36,7 @@ import type {
33
36
  BeforeApplicationDestroy,
34
37
  OnApplicationDestroy,
35
38
  } from './';
39
+ import type { ExceptionFilter } from './exception-filters/exception-filters';
36
40
  import type {
37
41
  SseEvent,
38
42
  SseGenerator,
@@ -41,6 +45,7 @@ import type {
41
45
  MiddlewareClass,
42
46
  OnModuleConfigure,
43
47
  } from './types';
48
+ import type { HttpExecutionContext } from './types';
44
49
  import type { ServerWebSocket } from 'bun';
45
50
 
46
51
  import {
@@ -125,6 +130,19 @@ import {
125
130
  DEFAULT_IDLE_TIMEOUT,
126
131
  DEFAULT_SSE_HEARTBEAT_MS,
127
132
  DEFAULT_SSE_TIMEOUT,
133
+ UseGuards,
134
+ AuthGuard,
135
+ RolesGuard,
136
+ createHttpGuard,
137
+ HttpExecutionContextImpl,
138
+ UseFilters,
139
+ createExceptionFilter,
140
+ defaultExceptionFilter,
141
+ HttpException,
142
+ CorsMiddleware,
143
+ RateLimitMiddleware,
144
+ MemoryRateLimitStore,
145
+ SecurityHeadersMiddleware,
128
146
  } from './';
129
147
 
130
148
 
@@ -1096,7 +1114,7 @@ describe('Middleware API Documentation Examples (docs/api/controllers.md)', () =
1096
1114
  }
1097
1115
  }
1098
1116
 
1099
- class CorsMiddleware extends BaseMiddleware {
1117
+ class ExampleCorsMiddleware extends BaseMiddleware {
1100
1118
  async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
1101
1119
  const response = await next();
1102
1120
  response.headers.set('Access-Control-Allow-Origin', '*');
@@ -1115,7 +1133,7 @@ describe('Middleware API Documentation Examples (docs/api/controllers.md)', () =
1115
1133
 
1116
1134
  const app = new OneBunApplication(AppModule, {
1117
1135
  port: 0,
1118
- middleware: [RequestIdMiddleware, CorsMiddleware],
1136
+ middleware: [RequestIdMiddleware, ExampleCorsMiddleware],
1119
1137
  });
1120
1138
 
1121
1139
  expect(app).toBeDefined();
@@ -1855,6 +1873,67 @@ describe('Lifecycle Hooks API Documentation Examples (docs/api/services.md)', ()
1855
1873
  // Database initialized first, then cache saw database was ready
1856
1874
  expect(initOrder).toEqual(['database', 'cache:db-ready=true']);
1857
1875
  });
1876
+
1877
+ /**
1878
+ * @source docs/api/services.md#lifecycle-hooks
1879
+ * onModuleInit is called for services in ALL modules across the entire
1880
+ * import tree, not just the root module. Deeply nested modules are
1881
+ * initialized in depth-first order.
1882
+ */
1883
+ it('should call onModuleInit for services across the entire module import tree', async () => {
1884
+ const moduleMod = await import('./module/module');
1885
+ const testUtils = await import('./testing/test-utils');
1886
+ const effectLib = await import('effect');
1887
+
1888
+ const initLog: string[] = [];
1889
+
1890
+ @Service()
1891
+ class AuthService extends BaseService implements OnModuleInit {
1892
+ async onModuleInit(): Promise<void> {
1893
+ initLog.push('auth');
1894
+ }
1895
+ }
1896
+
1897
+ @Module({
1898
+ providers: [AuthService],
1899
+ })
1900
+ class AuthModule {}
1901
+
1902
+ @Service()
1903
+ class UserService extends BaseService implements OnModuleInit {
1904
+ async onModuleInit(): Promise<void> {
1905
+ initLog.push('user');
1906
+ }
1907
+ }
1908
+
1909
+ @Module({
1910
+ imports: [AuthModule],
1911
+ providers: [UserService],
1912
+ })
1913
+ class UserModule {}
1914
+
1915
+ @Service()
1916
+ class AppService extends BaseService implements OnModuleInit {
1917
+ async onModuleInit(): Promise<void> {
1918
+ initLog.push('app');
1919
+ }
1920
+ }
1921
+
1922
+ @Module({
1923
+ imports: [UserModule],
1924
+ providers: [AppService],
1925
+ })
1926
+ class AppModule {}
1927
+
1928
+ const mod = new moduleMod.OneBunModule(AppModule, testUtils.makeMockLoggerLayer());
1929
+ await effectLib.Effect.runPromise(mod.setup() as import('effect').Effect.Effect<unknown, never, never>);
1930
+
1931
+ // All three modules' services should have onModuleInit called
1932
+ expect(initLog).toContain('auth');
1933
+ expect(initLog).toContain('user');
1934
+ expect(initLog).toContain('app');
1935
+ expect(initLog.length).toBe(3);
1936
+ });
1858
1937
  });
1859
1938
  });
1860
1939
 
@@ -5350,3 +5429,279 @@ describe('File Upload API Documentation (docs/api/controllers.md)', () => {
5350
5429
  expect(FileController).toBeDefined();
5351
5430
  });
5352
5431
  });
5432
+
5433
+ // ============================================================================
5434
+ // docs/api/guards.md examples
5435
+ // ============================================================================
5436
+
5437
+ describe('docs/api/guards.md', () => {
5438
+ it('createHttpGuard — function-based guard returns a class constructor', () => {
5439
+ const apiKeyGuardClass = createHttpGuard((ctx) => {
5440
+ return ctx.getRequest().headers.get('x-api-key') !== null;
5441
+ });
5442
+
5443
+ expect(apiKeyGuardClass).toBeDefined();
5444
+ // createHttpGuard returns a constructor — instantiate to get the guard instance
5445
+ const instance = new apiKeyGuardClass();
5446
+ expect(typeof instance.canActivate).toBe('function');
5447
+ });
5448
+
5449
+ it('AuthGuard blocks request without Bearer token', async () => {
5450
+ const guard = new AuthGuard();
5451
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5452
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5453
+
5454
+ expect(await guard.canActivate(ctx)).toBe(false);
5455
+ });
5456
+
5457
+ it('AuthGuard allows request with Bearer token', async () => {
5458
+ const guard = new AuthGuard();
5459
+ const req = new Request('http://localhost/', {
5460
+ headers: { authorization: 'Bearer my-token' },
5461
+ }) as unknown as OneBunRequest;
5462
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5463
+
5464
+ expect(await guard.canActivate(ctx)).toBe(true);
5465
+ });
5466
+
5467
+ it('RolesGuard checks x-user-roles header', async () => {
5468
+ const guard = new RolesGuard(['admin']);
5469
+ const h = new Headers();
5470
+ h.set('x-user-roles', 'admin,user');
5471
+ const req = new Request('http://localhost/', { headers: h }) as unknown as OneBunRequest;
5472
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5473
+
5474
+ expect(await guard.canActivate(ctx)).toBe(true);
5475
+ });
5476
+
5477
+ it('RolesGuard rejects when role not present', async () => {
5478
+ const guard = new RolesGuard(['admin']);
5479
+ const h = new Headers();
5480
+ h.set('x-user-roles', 'user');
5481
+ const req = new Request('http://localhost/', { headers: h }) as unknown as OneBunRequest;
5482
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5483
+
5484
+ expect(await guard.canActivate(ctx)).toBe(false);
5485
+ });
5486
+
5487
+ it('RolesGuard with custom role extractor', async () => {
5488
+ const guard = new RolesGuard(
5489
+ ['admin'],
5490
+ (ctx) => {
5491
+ const raw = ctx.getRequest().headers.get('x-custom-roles');
5492
+
5493
+ return raw ? raw.split(':') : [];
5494
+ },
5495
+ );
5496
+ const h = new Headers();
5497
+ h.set('x-custom-roles', 'admin:editor');
5498
+ const req = new Request('http://localhost/', { headers: h }) as unknown as OneBunRequest;
5499
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5500
+
5501
+ expect(await guard.canActivate(ctx)).toBe(true);
5502
+ });
5503
+
5504
+ it('@UseGuards applies metadata to controller', () => {
5505
+ @UseGuards(AuthGuard)
5506
+ @Controller('/protected')
5507
+ class ProtectedController extends BaseController {
5508
+ @Get('/')
5509
+ index() {
5510
+ return { message: 'authenticated' };
5511
+ }
5512
+ }
5513
+
5514
+ expect(ProtectedController).toBeDefined();
5515
+ });
5516
+ });
5517
+
5518
+ // ============================================================================
5519
+ // docs/api/exception-filters.md examples
5520
+ // ============================================================================
5521
+
5522
+ describe('docs/api/exception-filters.md', () => {
5523
+ it('createExceptionFilter — function-based filter', async () => {
5524
+ const filter = createExceptionFilter((error, _ctx) => {
5525
+ if (error instanceof Error) {
5526
+ return new Response(JSON.stringify({ caught: error.message }), { status: 200 });
5527
+ }
5528
+ throw error;
5529
+ });
5530
+
5531
+ expect(filter).toBeDefined();
5532
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5533
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5534
+ const res = await filter.catch(new Error('boom'), ctx);
5535
+ const body = await res.json() as { caught: string };
5536
+
5537
+ expect(body.caught).toBe('boom');
5538
+ });
5539
+
5540
+ it('class-based filter — re-throws unknown errors', async () => {
5541
+ class TypedFilter implements ExceptionFilter {
5542
+ catch(error: unknown, _ctx: HttpExecutionContext): Response {
5543
+ if (error instanceof RangeError) {
5544
+ return Response.json({ success: false, error: 'range' });
5545
+ }
5546
+ throw error;
5547
+ }
5548
+ }
5549
+
5550
+ const filter = new TypedFilter();
5551
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5552
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5553
+
5554
+ const res = await filter.catch(new RangeError('out of range'), ctx);
5555
+ const body = await res.json() as { success: boolean; error: string };
5556
+
5557
+ expect(body.success).toBe(false);
5558
+ expect(body.error).toBe('range');
5559
+ });
5560
+
5561
+ it('defaultExceptionFilter handles OneBunBaseError subclass', async () => {
5562
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5563
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5564
+ const res = await defaultExceptionFilter.catch(new NotFoundError('not found'), ctx);
5565
+ const body = await res.json() as { success: boolean };
5566
+
5567
+ expect(body.success).toBe(false);
5568
+ });
5569
+
5570
+ it('async filter resolves correctly', async () => {
5571
+ const asyncFilter = createExceptionFilter(async (error, _ctx) => {
5572
+ await Promise.resolve(); // simulate async work
5573
+
5574
+ return new Response(JSON.stringify({ async: true, msg: String(error) }));
5575
+ });
5576
+
5577
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5578
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5579
+ const res = await asyncFilter.catch(new Error('err'), ctx);
5580
+ const body = await res.json() as { async: boolean };
5581
+
5582
+ expect(body.async).toBe(true);
5583
+ });
5584
+
5585
+ it('HttpException — carries statusCode', () => {
5586
+ const ex = new HttpException(400, 'Bad request');
5587
+
5588
+ expect(ex).toBeInstanceOf(Error);
5589
+ expect(ex.statusCode).toBe(400);
5590
+ expect(ex.message).toBe('Bad request');
5591
+ });
5592
+
5593
+ it('defaultExceptionFilter returns real HTTP status for HttpException', async () => {
5594
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5595
+ const ctx = new HttpExecutionContextImpl(req, 'handler', 'Controller');
5596
+ const res = await defaultExceptionFilter.catch(
5597
+ new HttpException(404, 'Not found'),
5598
+ ctx,
5599
+ );
5600
+
5601
+ expect(res.status).toBe(404);
5602
+ const body = await res.json() as { success: boolean; error: string };
5603
+ expect(body.success).toBe(false);
5604
+ expect(body.error).toBe('Not found');
5605
+ });
5606
+
5607
+ it('@UseFilters applies metadata to controller', () => {
5608
+ const filter = createExceptionFilter((err, _ctx) => {
5609
+ throw err;
5610
+ });
5611
+
5612
+ @UseFilters(filter)
5613
+ @Controller('/filtered')
5614
+ class FilteredController extends BaseController {
5615
+ @Get('/')
5616
+ index() {
5617
+ return {};
5618
+ }
5619
+ }
5620
+
5621
+ expect(FilteredController).toBeDefined();
5622
+ });
5623
+ });
5624
+
5625
+ // ============================================================================
5626
+ // docs/api/security.md examples
5627
+ // ============================================================================
5628
+
5629
+ describe('docs/api/security.md', () => {
5630
+ it('CorsMiddleware — default wildcard origin', async () => {
5631
+ const mw = new CorsMiddleware();
5632
+ const req = new Request('http://localhost/', {
5633
+ headers: { origin: 'https://example.com' },
5634
+ }) as unknown as OneBunRequest;
5635
+
5636
+ const res = await mw.use(req, async () => new Response('ok'));
5637
+
5638
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*');
5639
+ });
5640
+
5641
+ it('CorsMiddleware — preflight returns 204', async () => {
5642
+ const mw = new CorsMiddleware();
5643
+ const req = new Request('http://localhost/', {
5644
+ method: 'OPTIONS',
5645
+ headers: { origin: 'https://example.com' },
5646
+ }) as unknown as OneBunRequest;
5647
+
5648
+ const res = await mw.use(req, async () => new Response('ok'));
5649
+
5650
+ expect(res.status).toBe(204);
5651
+ });
5652
+
5653
+ it('CorsMiddleware.configure() factory', async () => {
5654
+ const configuredClass = CorsMiddleware.configure({ origin: 'https://trusted.com' });
5655
+ const mw = new configuredClass();
5656
+ const req = new Request('http://localhost/', {
5657
+ headers: { origin: 'https://trusted.com' },
5658
+ }) as unknown as OneBunRequest;
5659
+
5660
+ const res = await mw.use(req, async () => new Response('ok'));
5661
+
5662
+ expect(res.headers.get('Access-Control-Allow-Origin')).toBe('https://trusted.com');
5663
+ });
5664
+
5665
+ it('RateLimitMiddleware — allows below max', async () => {
5666
+ const store = new MemoryRateLimitStore();
5667
+ const mw = new RateLimitMiddleware({ max: 5, windowMs: 60_000, store });
5668
+ const h1 = new Headers();
5669
+ h1.set('x-forwarded-for', '1.2.3.4');
5670
+ const req = new Request('http://localhost/', { headers: h1 }) as unknown as OneBunRequest;
5671
+
5672
+ const res = await mw.use(req, async () => new Response('ok'));
5673
+
5674
+ expect(res.status).toBe(200);
5675
+ });
5676
+
5677
+ it('RateLimitMiddleware — returns 429 when over limit', async () => {
5678
+ const store = new MemoryRateLimitStore();
5679
+ const mw = new RateLimitMiddleware({ max: 1, windowMs: 60_000, store });
5680
+ const h2 = new Headers();
5681
+ h2.set('x-forwarded-for', '9.9.9.9');
5682
+ const req = new Request('http://localhost/', { headers: h2 }) as unknown as OneBunRequest;
5683
+ await mw.use(req, async () => new Response('ok'));
5684
+ const res = await mw.use(req, async () => new Response('ok'));
5685
+
5686
+ expect(res.status).toBe(429);
5687
+ });
5688
+
5689
+ it('SecurityHeadersMiddleware — sets X-Frame-Options', async () => {
5690
+ const mw = new SecurityHeadersMiddleware();
5691
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5692
+
5693
+ const res = await mw.use(req, async () => new Response('ok'));
5694
+
5695
+ expect(res.headers.get('X-Frame-Options')).toBe('SAMEORIGIN');
5696
+ expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff');
5697
+ });
5698
+
5699
+ it('SecurityHeadersMiddleware — disabled header is absent', async () => {
5700
+ const mw = new SecurityHeadersMiddleware({ strictTransportSecurity: false });
5701
+ const req = new Request('http://localhost/') as unknown as OneBunRequest;
5702
+
5703
+ const res = await mw.use(req, async () => new Response('ok'));
5704
+
5705
+ expect(res.headers.get('Strict-Transport-Security')).toBeNull();
5706
+ });
5707
+ });