@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.
@@ -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
@@ -132,3 +132,12 @@ export * from './validation';
132
132
 
133
133
  // Testing Utilities
134
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)', () => {
@@ -72,6 +72,16 @@ export function clearGlobalServicesRegistry(): void {
72
72
  processedGlobalModules.clear();
73
73
  }
74
74
 
75
+ /**
76
+ * Register a service instance in the global services registry so it is available
77
+ * in all modules (including child modules) via PHASE 0 of module initialization.
78
+ * Must be called BEFORE creating the root module.
79
+ * @internal
80
+ */
81
+ export function registerGlobalService<T>(tag: Context.Tag<unknown, T>, instance: T): void {
82
+ globalServicesRegistry.set(tag as Context.Tag<unknown, unknown>, instance);
83
+ }
84
+
75
85
  /**
76
86
  * Get all global services (useful for debugging)
77
87
  * @internal
@@ -627,7 +637,21 @@ export class OneBunModule implements ModuleInstance {
627
637
  }
628
638
  }
629
639
 
630
- // Find service instance that matches the type
640
+ // Try to find by Effect Context.Tag first.
641
+ // This is the primary mechanism and also makes test overrides work:
642
+ // TestingModule.overrideProvider(MyService).useValue(mock) registers the mock
643
+ // under MyService's tag, so it is found here even if mock is not instanceof MyService.
644
+ try {
645
+ const tag = getServiceTag(type as new (...args: unknown[]) => unknown);
646
+ const byTag = this.serviceInstances.get(tag as Context.Tag<unknown, unknown>);
647
+ if (byTag !== undefined) {
648
+ return byTag;
649
+ }
650
+ } catch {
651
+ // Not a @Service()-decorated class — fall through to instanceof check below
652
+ }
653
+
654
+ // Fallback: find service instance that matches the type by reference equality or inheritance
631
655
  const serviceInstance = Array.from(this.serviceInstances.values()).find((instance) => {
632
656
  if (!instance) {
633
657
  return false;
@@ -689,11 +713,27 @@ export class OneBunModule implements ModuleInstance {
689
713
  /**
690
714
  * Setup the module and its dependencies
691
715
  */
716
+ /**
717
+ * Collect all descendant modules in depth-first order (leaves first).
718
+ * This ensures that deeply nested modules are initialized before their parents.
719
+ */
720
+ private collectDescendantModules(): OneBunModule[] {
721
+ const result: OneBunModule[] = [];
722
+ for (const child of this.childModules) {
723
+ result.push(...child.collectDescendantModules());
724
+ result.push(child);
725
+ }
726
+
727
+ return result;
728
+ }
729
+
692
730
  setup(): Effect.Effect<unknown, never, void> {
731
+ const allDescendants = this.collectDescendantModules();
732
+
693
733
  return this.callServicesOnModuleInit().pipe(
694
- // Also run onModuleInit for child modules' services
734
+ // Run onModuleInit for all descendant modules' services (depth-first)
695
735
  Effect.flatMap(() =>
696
- Effect.forEach(this.childModules, (childModule) => childModule.callServicesOnModuleInit(), {
736
+ Effect.forEach(allDescendants, (mod) => mod.callServicesOnModuleInit(), {
697
737
  discard: true,
698
738
  }),
699
739
  ),
@@ -704,18 +744,18 @@ export class OneBunModule implements ModuleInstance {
704
744
  this.resolveModuleMiddleware();
705
745
  }),
706
746
  ),
707
- // Create controller instances in child modules first, then this module (each uses its own DI scope)
747
+ // Create controller instances in all descendant modules first, then this module
708
748
  Effect.flatMap(() =>
709
- Effect.forEach(this.childModules, (childModule) => childModule.createControllerInstances(), {
749
+ Effect.forEach(allDescendants, (mod) => mod.createControllerInstances(), {
710
750
  discard: true,
711
751
  }),
712
752
  ),
713
753
  Effect.flatMap(() => this.createControllerInstances()),
714
754
  // Then call onModuleInit for controllers
715
755
  Effect.flatMap(() => this.callControllersOnModuleInit()),
716
- // Also run onModuleInit for child modules' controllers
756
+ // Run onModuleInit for all descendant modules' controllers
717
757
  Effect.flatMap(() =>
718
- Effect.forEach(this.childModules, (childModule) => childModule.callControllersOnModuleInit(), {
758
+ Effect.forEach(allDescendants, (mod) => mod.callControllersOnModuleInit(), {
719
759
  discard: true,
720
760
  }),
721
761
  ),
@@ -435,7 +435,7 @@ describe('Custom adapter NATS JetStream (docs/api/queue.md)', () => {
435
435
  class NatsJetStreamAdapter implements QueueAdapter {
436
436
  readonly name = 'nats-jetstream';
437
437
  readonly type = 'jetstream';
438
- constructor(private opts: { servers: string; stream?: string }) {}
438
+ constructor(private opts: { servers: string; streams?: Array<{ name: string; subjects: string[] }> }) {}
439
439
  async connect(): Promise<void> {}
440
440
  async disconnect(): Promise<void> {}
441
441
  isConnected(): boolean {
@@ -473,7 +473,7 @@ describe('Custom adapter NATS JetStream (docs/api/queue.md)', () => {
473
473
 
474
474
  const adapter = new NatsJetStreamAdapter({
475
475
  servers: 'nats://localhost:4222',
476
- stream: 'EVENTS',
476
+ streams: [{ name: 'EVENTS', subjects: ['events.>'] }],
477
477
  });
478
478
  await adapter.connect();
479
479
  expect(adapter.name).toBe('nats-jetstream');
@@ -0,0 +1,212 @@
1
+ import type { OneBunRequest, OneBunResponse } from '../types';
2
+
3
+ import { BaseMiddleware } from '../module/middleware';
4
+
5
+ /**
6
+ * CORS (Cross-Origin Resource Sharing) configuration options.
7
+ * Passed via `ApplicationOptions.cors`.
8
+ */
9
+ export interface CorsOptions {
10
+ /**
11
+ * Allowed origin(s).
12
+ * - `'*'` — allow any origin
13
+ * - `string` — exact match
14
+ * - `RegExp` — regex match
15
+ * - `string[]` / `RegExp[]` — list of allowed origins
16
+ * - `(origin: string) => boolean` — custom predicate
17
+ * @defaultValue '*'
18
+ */
19
+ origin?: string | RegExp | Array<string | RegExp> | ((origin: string) => boolean);
20
+
21
+ /**
22
+ * Allowed HTTP methods.
23
+ * @defaultValue ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS']
24
+ */
25
+ methods?: string[];
26
+
27
+ /**
28
+ * Allowed request headers.
29
+ * @defaultValue ['Content-Type', 'Authorization']
30
+ */
31
+ allowedHeaders?: string[];
32
+
33
+ /**
34
+ * Headers to expose to the browser.
35
+ */
36
+ exposedHeaders?: string[];
37
+
38
+ /**
39
+ * Allow credentials (cookies, Authorization header).
40
+ * @defaultValue false
41
+ */
42
+ credentials?: boolean;
43
+
44
+ /**
45
+ * Preflight cache duration in seconds.
46
+ * @defaultValue 86400 (24 hours)
47
+ */
48
+ maxAge?: number;
49
+
50
+ /**
51
+ * Whether to pass the CORS preflight request to the next handler.
52
+ * When `false` (default) preflight requests are handled by the middleware
53
+ * and never reach route handlers.
54
+ * @defaultValue false
55
+ */
56
+ preflightContinue?: boolean;
57
+ }
58
+
59
+ const DEFAULT_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'];
60
+ const DEFAULT_ALLOWED_HEADERS = ['Content-Type', 'Authorization'];
61
+ const DEFAULT_MAX_AGE = 86400;
62
+ const NO_CONTENT = 204;
63
+
64
+ /**
65
+ * Resolves whether the given `origin` is allowed by the CORS configuration.
66
+ */
67
+ function isOriginAllowed(
68
+ origin: string,
69
+ allowed: NonNullable<CorsOptions['origin']>,
70
+ ): boolean {
71
+ if (allowed === '*') {
72
+ return true;
73
+ }
74
+
75
+ if (typeof allowed === 'string') {
76
+ return origin === allowed;
77
+ }
78
+
79
+ if (allowed instanceof RegExp) {
80
+ return allowed.test(origin);
81
+ }
82
+
83
+ if (typeof allowed === 'function') {
84
+ return allowed(origin);
85
+ }
86
+
87
+ return (allowed as Array<string | RegExp>).some((item) =>
88
+ typeof item === 'string' ? origin === item : item.test(origin),
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Built-in CORS middleware.
94
+ *
95
+ * Handles preflight `OPTIONS` requests and sets appropriate CORS response
96
+ * headers for all other requests. Configure once via `ApplicationOptions.cors`
97
+ * and the framework will instantiate this middleware automatically.
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * const app = new OneBunApplication(AppModule, {
102
+ * cors: {
103
+ * origin: 'https://my-frontend.example.com',
104
+ * credentials: true,
105
+ * },
106
+ * });
107
+ * ```
108
+ *
109
+ * When using manually (e.g. from `ApplicationOptions.middleware`):
110
+ * ```typescript
111
+ * const app = new OneBunApplication(AppModule, {
112
+ * middleware: [CorsMiddleware.configure({ origin: /example\.com$/ })],
113
+ * });
114
+ * ```
115
+ */
116
+ export class CorsMiddleware extends BaseMiddleware {
117
+ private readonly options: Required<
118
+ Pick<CorsOptions, 'methods' | 'allowedHeaders' | 'maxAge' | 'credentials' | 'preflightContinue'>
119
+ > &
120
+ Pick<CorsOptions, 'origin' | 'exposedHeaders'>;
121
+
122
+ /**
123
+ * Create a pre-configured CorsMiddleware class with the given options.
124
+ * Returns a constructor — pass the result directly to `ApplicationOptions.middleware`.
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * const app = new OneBunApplication(AppModule, {
129
+ * middleware: [CorsMiddleware.configure({ origin: 'https://example.com' })],
130
+ * });
131
+ * ```
132
+ */
133
+ static configure(options: CorsOptions = {}): typeof CorsMiddleware {
134
+ class ConfiguredCorsMiddleware extends CorsMiddleware {
135
+ constructor() {
136
+ super(options);
137
+ }
138
+ }
139
+
140
+ return ConfiguredCorsMiddleware;
141
+ }
142
+
143
+ constructor(options: CorsOptions = {}) {
144
+ super();
145
+
146
+ this.options = {
147
+ origin: options.origin ?? '*',
148
+ methods: options.methods ?? DEFAULT_METHODS,
149
+ allowedHeaders: options.allowedHeaders ?? DEFAULT_ALLOWED_HEADERS,
150
+ exposedHeaders: options.exposedHeaders,
151
+ credentials: options.credentials ?? false,
152
+ maxAge: options.maxAge ?? DEFAULT_MAX_AGE,
153
+ preflightContinue: options.preflightContinue ?? false,
154
+ };
155
+ }
156
+
157
+ async use(req: OneBunRequest, next: () => Promise<OneBunResponse>): Promise<OneBunResponse> {
158
+ const origin = req.headers.get('origin') ?? '';
159
+ const headers = new Headers();
160
+
161
+ // Determine the effective origin for the Access-Control-Allow-Origin header
162
+ const { origin: allowedOrigin } = this.options;
163
+ const allowAll = allowedOrigin === '*' && !this.options.credentials;
164
+
165
+ if (allowAll) {
166
+ headers.set('Access-Control-Allow-Origin', '*');
167
+ } else if (origin && isOriginAllowed(origin, allowedOrigin ?? '*')) {
168
+ headers.set('Access-Control-Allow-Origin', origin);
169
+ headers.append('Vary', 'Origin');
170
+ } else if (!origin) {
171
+ // Non-browser request (no Origin header) — set wildcard if configured
172
+ if (allowedOrigin === '*') {
173
+ headers.set('Access-Control-Allow-Origin', '*');
174
+ }
175
+ }
176
+
177
+ if (this.options.credentials) {
178
+ headers.set('Access-Control-Allow-Credentials', 'true');
179
+ }
180
+
181
+ if (this.options.exposedHeaders && this.options.exposedHeaders.length > 0) {
182
+ headers.set('Access-Control-Expose-Headers', this.options.exposedHeaders.join(', '));
183
+ }
184
+
185
+ // Handle preflight OPTIONS request
186
+ if (req.method === 'OPTIONS') {
187
+ headers.set('Access-Control-Allow-Methods', this.options.methods.join(', '));
188
+ headers.set('Access-Control-Allow-Headers', this.options.allowedHeaders.join(', '));
189
+ headers.set('Access-Control-Max-Age', String(this.options.maxAge));
190
+
191
+ if (this.options.preflightContinue) {
192
+ const response = await next();
193
+ // Copy CORS headers onto the response from next()
194
+ for (const [key, value] of headers.entries()) {
195
+ response.headers.set(key, value);
196
+ }
197
+
198
+ return response;
199
+ }
200
+
201
+ return new Response(null, { status: NO_CONTENT, headers });
202
+ }
203
+
204
+ // For non-OPTIONS requests — let the handler run, then attach CORS headers
205
+ const response = await next();
206
+ for (const [key, value] of headers.entries()) {
207
+ response.headers.set(key, value);
208
+ }
209
+
210
+ return response;
211
+ }
212
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Security Middleware
3
+ *
4
+ * Built-in middleware for common security concerns: CORS, rate limiting,
5
+ * and HTTP security headers.
6
+ */
7
+
8
+ export { CorsMiddleware, type CorsOptions } from './cors-middleware';
9
+ export {
10
+ RateLimitMiddleware,
11
+ MemoryRateLimitStore,
12
+ RedisRateLimitStore,
13
+ type RateLimitOptions,
14
+ type RateLimitStore,
15
+ } from './rate-limit-middleware';
16
+ export {
17
+ SecurityHeadersMiddleware,
18
+ type SecurityHeadersOptions,
19
+ } from './security-headers-middleware';