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
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 }. handlerreturnstrue, data(or justdata) on success, andfalse, 'error_code',false, 'A message'orfalse, { code, message }on failure.- If the success
datahas anotifytable and the call came from a row or radial item (server = …), the client shows it as a toast.callServerfrom code just returnsdata. - 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:
- Global token bucket per player over everything np_menu receives (
Config.RateLimit.perSecond/burst) - Payload sanity: a table, depth ≤ 8, ≤ 512 keys, strings ≤ 4096 bytes, finite numbers, about
Config.RateLimit.maxPayload(16 KB) of JSON at most - Per-action rate limit (
rate), then busy (exclusive) - Ace (
IsPlayerAceAllowed) - Groups / jobs through the framework bridge
validate(src, payload)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:
{ 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):
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.
| Code | Meaning |
|---|---|
rate_limited | Global bucket empty, or the action's rate hasn't passed |
busy | The previous call of this action is still running (exclusive) |
no_permission | Ace, group or job check failed |
invalid_payload | Not a table, failed the sanity check, or validate returned false (or errored) |
unknown_action | No action with that name |
internal_error | The handler threw an error (logged on the server) |
failed | The handler returned false without a code |
timeout | No answer within 10 s |
cooldown | The row is on cooldown (client side) |
| your own | Any ^[%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:
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
| Export | Description |
|---|---|
registerAction(name, opts) -> boolean | See above |
removeAction(name) -> boolean | Unregister 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.