Security

FiveM is a hostile environment — every @OnClient event handler is a public HTTP endpoint in all but name. Merinaa ships three building blocks you should know about.

Guards — authorization

Guards are classes that implement canActivate(context) and return boolean. Attached via @UseGuards(SomeGuard) on a controller or a specific handler, they run before the handler. Return false and the handler is skipped silently.

import { Injectable } from '@merinaa/core';
import type { Guard, ExecutionContext } from '@merinaa/core';
import { PermissionService } from '@merinaa/server';

@Injectable()
export class AdminGuard implements Guard {
    constructor(private perms: PermissionService) {}

    async canActivate(context: ExecutionContext): Promise<boolean> {
        return this.perms.has(context.source, 'admin:use');
    }
}

@Controller()
export class AdminController {
    @OnClient('admin:kick')
    @UseGuards(AdminGuard)
    kick(src: number, targetId: number) {
        DropPlayer(targetId.toString(), 'You were kicked');
    }
}

Rate limiting

RateLimitService is a built-in token-bucket limiter. Configure it in framework.config.yaml (or app.framework.rateLimit in merinaa.config.ts) — if any rateLimit.* key is set, bootstrapServer() automatically registers the global RateLimitGuard.

# framework.config.yaml
rateLimit:
    windowMs: 1000          # default window
    max: 20                 # default max calls per window per source
    events:
        bank:deposit:
            windowMs: 5000
            max: 3          # tighter — real money flow
        admin:kick:
            windowMs: 1000
            max: 1

Throttled calls are logged at warn level and silently rejected — no error propagates to the client.

Input validation

Merinaa doesn't validate @OnClient arguments for you. The client can send anything. Two defensive patterns:

1. Pipes — for transforming + validating arguments:

@OnClient('bank:deposit')
deposit(
    src: number,
    @Body(new ValidationPipe({ min: 1, max: 100_000 })) amount: number,
) {
    // amount is guaranteed to be 1..100_000 here
}

2. Deepkit runtime types — combined with Deepkit's type-compiler, you can validate full object shapes:

interface DepositRequest {
    accountId: string & MinLength<1>;
    amount: number & Positive & Maximum<100_000>;
}

@OnClient('bank:deposit')
deposit(src: number, request: DepositRequest) {
    // Deepkit + ValidationPipe rejects malformed requests
}

Common pitfalls

  • Trusting the source of truth@Source() src is the only thing you can trust from a client call. Don't accept a player id as a parameter and skip authorization.

  • Admin commands registered via RegisterCommand run in the FiveM command dispatcher, not via @OnClient — they bypass guards. If you register an admin command client-side, verify admin status on the server via net-events, not just in the command body.

  • NUI callbacks (RegisterNuiCallbackType) also run outside the @OnClient flow and bypass guards. If your UI has a "make me admin" button, authorize on the server.