Skip to content

Server actions & security ​

The menu is a display. Whatever the client sends can be forged, so anything that touches money, items, other players or admin powers runs as a server action: a named handler on the server that np_menu calls for the client after checking rate limits, permissions and the payload.

registerAction ​

server

lua
exports.np_menu:registerAction('my_garage:scrap', {
  ace = 'my_garage.manage',          -- optional: string or list (any of)
  groups = { mechanic = 2 },         -- optional: framework groups, 'job' | { 'a', 'b' } | { job = minGrade }
  jobs = 'mechanic', onduty = true,  -- optional: checked against the player's job (+ duty)
  rate = 2000,                       -- optional: ms between two calls per player (default 250)
  exclusive = true,                  -- default: one call in flight per player and action
  validate = function(src, payload)  -- optional: return false, 'code' to reject
    return type(payload.args) == 'table' and type(payload.args.plate) == 'string'
  end,
  handler = function(src, payload)
    local plate = payload.args.plate
    if not ownsVehicle(src, plate) then return false, 'not_owner' end
    scrap(plate)
    return true, { notify = { type = 'success', title = 'Scrapped', message = ('**%s** is gone.'):format(plate) } }
  end,
})

With the import wrapper on the server: NpMenu.registerAction(name, opts).

  • name: up to 64 characters of letters, digits and _ - . : /. Prefix it with your resource name.
  • The payload is always a table. From a menu row it is { value = <row value>, args = item.args }.
  • handler returns true, data (or just data) on success, and false, 'error_code', false, 'A message' or false, { code, message } on failure.
  • If the success data has a notify table and the call came from a row or radial item (server = …), the client shows it as a toast. callServer from code just returns data.
  • Actions are removed when the resource that registered them stops. removeAction(name) removes one by hand.

Check order ​

Every call goes through the same pipeline, and every step is pcall-protected:

  1. Global token bucket per player over everything np_menu receives (Config.RateLimit.perSecond / burst)
  2. Payload sanity: a table, depth ≤ 8, ≤ 512 keys, strings ≤ 4096 bytes, finite numbers, about Config.RateLimit.maxPayload (16 KB) of JSON at most
  3. Per-action rate limit (rate), then busy (exclusive)
  4. Ace (IsPlayerAceAllowed)
  5. Groups / jobs through the framework bridge
  6. validate(src, payload)
  7. handler(src, payload)

Calling an action ​

From a row, set server and args. The row shows a spinner while the call runs and an error toast if it fails:

lua
{ type = 'action', variant = 'danger', hold = 1200, label = 'Scrap vehicle', icon = 'trash', tint = 'red',
  server = 'my_garage:scrap', args = { plate = 'ABC123' },
  onResult = function(ok, data, item, menu, message)
    if ok then exports.np_menu:close() end
  end }

The action runs after onSelect / onChange, and only if they didn't return false.

From code, callServer blocks until the answer arrives (10 s timeout):

lua
local ok, data, message = exports.np_menu:callServer('my_garage:scrap', { args = { plate = 'ABC123' } })
-- ok = false: data is the error code, message the optional text. An error toast was already shown.

local ok, data = exports.np_menu:callServer('my_garage:list', {}, { silent = true, timeout = 5000 })

Radial items take server, args and onResult(ok, data, item, info) the same way.

Error codes ​

A failed call shows an error toast with the localized text for error.<code> (falling back to Something went wrong), or your message when the handler returned one.

CodeMeaning
rate_limitedGlobal bucket empty, or the action's rate hasn't passed
busyThe previous call of this action is still running (exclusive)
no_permissionAce, group or job check failed
invalid_payloadNot a table, failed the sanity check, or validate returned false (or errored)
unknown_actionNo action with that name
internal_errorThe handler threw an error (logged on the server)
failedThe handler returned false without a code
timeoutNo answer within 10 s
cooldownThe row is on cooldown (client side)
your ownAny ^[%w_.:-]+$ string your handler returns. Add a locale key error.<code> to translate it

Add translations for your own codes with NpMenu.addLocale in a shared file of np_menu, for example shared/locales/my_codes.lua:

lua
NpMenu.addLocale('en', { ['error.not_owner'] = "That isn't your vehicle." })
NpMenu.addLocale('de', { ['error.not_owner'] = 'Das ist nicht dein Fahrzeug.' })

Other server exports ​

ExportDescription
registerAction(name, opts) -> booleanSee above
removeAction(name) -> booleanUnregister an action
notify(src, data)Toast on one client ({ type, title, message, icon, tint, duration })
closeMenus(src)Close every np_menu surface on that client (use -1 for everyone)
resetAce(src?)Make one client (or all) re-query their aces, e.g. after granting a permission at runtime

The console / admin command npmenu_aces_reset calls resetAce() for everyone.

Client-side permissions ​

ace, groups, jobs and canInteract on rows only decide what the player sees. Ace answers come from the server (np_menu:ace → np_menu:aceResult), are cached per client and limited to Config.RateLimit.aceQueries (64) distinct aces per player. A hidden row is not a security boundary: always put the real check in the server action.

Rules for your handlers ​

  • Re-check everything on the server: distances (server-side ped coords), ownership, money, items, job and duty.
  • Never take a price, amount or target from the client without clamping and validating it. The built-in shops send a list of changes and the server recomputes the price.
  • Return an error code instead of throwing.
  • Fire your own server events only after validation.

np_* FiveM resources