Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
## OpenCore Framework v1.0.13
## OpenCore Framework v1.1.0

### Fixed
- Fixed `@Guard` rejecting valid players with `clientID: 0`, which affected the first RageMP player connected to a fresh server.
- Fixed `@Throttle` skipping rate-limit enforcement for valid players with `clientID: 0`, preventing an unthrottled first-player security bypass on RageMP.
### Added
- `@Controller()` now accepts an optional `ControllerOptions` object to define **default decorations** applied automatically to every method in the class.
- `guard` — applies `@Guard` to all methods that do not declare their own.
- `throttle` — applies `@Throttle` (supports both object and `[limit, windowMs]` tuple) to all methods that do not declare their own.
- `requiresState` — applies `@RequiresState` to all methods that do not declare their own.
- `public` — applies `@Public` to all methods that are not already marked `@Public()`.
- Explicit method-level decorators always **override** controller-level defaults. No breaking changes.

### Tests
- Added regression coverage for `@Guard` and `@Throttle` with `clientID: 0`.
- Added comprehensive unit tests for controller default decorations and override behavior.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@open-core/framework",
"version": "1.0.13",
"version": "1.1.0",
"description": "Secure, event-driven TypeScript Framework & Runtime engine for CitizenFX (Cfx).",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
114 changes: 112 additions & 2 deletions src/runtime/server/decorators/controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { injectable } from 'tsyringe'
import { ClassConstructor } from '../../../kernel/di/class-constructor'
import { Guard, GuardOptions } from './guard'
import { Public } from './public'
import { RequiresState, StateRequirement } from './requiresState'
import { Throttle, ThrottleOptions } from './throttle'
import { METADATA_KEYS } from '../system/metadata-server.keys'

export const _serverControllerRegistryByResource = new Map<string, Set<ClassConstructor>>()
Expand All @@ -23,14 +27,105 @@ export function getServerControllerRegistry(resourceName?: string): ClassConstru
return Array.from(registry)
}

export interface ControllerOptions {
/**
* Default `@Guard` applied to every method that does not define its own.
*/
guard?: GuardOptions
/**
* Default `@Throttle` applied to every method that does not define its own.
* Can be a full options object or a tuple `[limit, windowMs]`.
*/
throttle?: ThrottleOptions | [limit: number, windowMs?: number]
/**
* Default `@RequiresState` applied to every method that does not define its own.
*/
requiresState?: StateRequirement
/**
* When `true`, applies `@Public` to every method that is not already marked `@Public()`.
*/
public?: boolean
}

/**
* Applies default decorators from `ControllerOptions` to every own method on the
* prototype that does not already carry the corresponding metadata.
*
* This runs when `@Controller(options)` is evaluated, which in TypeScript happens
* *after* all method decorators have been applied. Therefore the defaults wrap
* around any existing method wrappers as the outermost layer.
*/
function applyControllerDefaults(target: ClassConstructor, options: ControllerOptions): void {
const prototype = target.prototype
const methods = Object.getOwnPropertyNames(prototype).filter(
(m) => m !== 'constructor' && typeof prototype[m] === 'function',
)

for (const methodName of methods) {
let descriptor = Object.getOwnPropertyDescriptor(prototype, methodName)
if (!descriptor || typeof descriptor.value !== 'function') continue

// --- Guard ---
if (options.guard && !Reflect.hasMetadata('core:guard', prototype, methodName)) {
const result = Guard(options.guard)(prototype, methodName, descriptor)
if (result) {
Object.defineProperty(prototype, methodName, result)
descriptor = result
}
}

// --- Throttle ---
if (options.throttle && !Reflect.hasMetadata(METADATA_KEYS.THROTTLE, prototype, methodName)) {
const d = descriptor
const throttleArgs: [number | ThrottleOptions, number?] = Array.isArray(options.throttle)
? options.throttle
: [options.throttle]
const result = Throttle(...throttleArgs)(prototype, methodName, d)
if (result) {
Object.defineProperty(prototype, methodName, result)
descriptor = result
}
}

// --- RequiresState ---
if (
options.requiresState &&
!Reflect.hasMetadata(METADATA_KEYS.REQUIRES_STATE, prototype, methodName)
) {
const d = descriptor
const result = RequiresState(options.requiresState)(prototype, methodName, d)
if (result) {
Object.defineProperty(prototype, methodName, result)
descriptor = result
}
}

// --- Public ---
if (
options.public === true &&
!Reflect.hasMetadata(METADATA_KEYS.PUBLIC, prototype, methodName)
) {
const d = descriptor
const result = Public()(prototype, methodName, d)
if (result) {
Object.defineProperty(prototype, methodName, result)
descriptor = result
}
}
}
}

/**
* Class decorator used to mark a class as a Server Controller.
*
* This decorator performs the following actions:
* 1. Marks the class as `@injectable` (via tsyringe) for dependency injection.
* 2. Defines metadata identifying the class as a 'server' type controller.
* 3. Automatically adds the class constructor to the `_serverControllerRegistryByResource`.
* 4. When `options` are provided, applies default decorators to every method that
* does not already define them explicitly.
*
* @param options - Optional configuration for default decorations.
* @returns The decorator function to apply to the class.
*
* @example
Expand All @@ -44,17 +139,32 @@ export function getServerControllerRegistry(resourceName?: string): ClassConstru
* }
* }
* ```
*
* @example
* ```ts
* @Server.Controller({
* guard: { permission: 'user.authenticated' },
* throttle: { limit: 20, windowMs: 1000 },
* })
* export class ShopController {
* // All methods inherit guard and throttle by default
* }
* ```
*/
export function Controller(): (target: ClassConstructor) => void {
export function Controller(options?: ControllerOptions): (target: ClassConstructor) => void {
return (target: ClassConstructor) => {
injectable()(target)
Reflect.defineMetadata(METADATA_KEYS.CONTROLLER, { type: 'server' }, target)
Reflect.defineMetadata(METADATA_KEYS.CONTROLLER, { type: 'server', options }, target)
const key = getCurrentResourceNameSafe()
let registry = _serverControllerRegistryByResource.get(key)
if (!registry) {
registry = new Set<ClassConstructor>()
_serverControllerRegistryByResource.set(key, registry)
}
registry.add(target)

if (options) {
applyControllerDefaults(target, options)
}
}
}
2 changes: 1 addition & 1 deletion src/runtime/server/decorators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export * from './binaryEvent'
export * from './binaryService'
export * from './bind'
export { Command, CommandConfig } from './command'
export { Controller } from './controller'
export { Controller, ControllerOptions } from './controller'
export * from './export'
export * from './guard'
export * from './onFrameworkEvent'
Expand Down
Loading
Loading