@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.
- package/package.json +1 -1
- package/src/application/application.test.ts +52 -6
- package/src/application/application.ts +308 -220
- package/src/decorators/decorators.ts +213 -0
- package/src/docs-examples.test.ts +357 -2
- 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 +9 -0
- package/src/module/module.test.ts +78 -0
- package/src/module/module.ts +47 -7
- package/src/queue/docs-examples.test.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 +98 -0
|
@@ -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)', () => {
|
package/src/module/module.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
734
|
+
// Run onModuleInit for all descendant modules' services (depth-first)
|
|
695
735
|
Effect.flatMap(() =>
|
|
696
|
-
Effect.forEach(
|
|
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
|
|
747
|
+
// Create controller instances in all descendant modules first, then this module
|
|
708
748
|
Effect.flatMap(() =>
|
|
709
|
-
Effect.forEach(
|
|
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
|
-
//
|
|
756
|
+
// Run onModuleInit for all descendant modules' controllers
|
|
717
757
|
Effect.flatMap(() =>
|
|
718
|
-
Effect.forEach(
|
|
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;
|
|
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
|
-
|
|
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';
|