-- Shared helpers: payload decoding, the game-server auth guard, and small -- storage conveniences used by every other module. local nk = require("nakama") local config = require("config") local M = {} -- Nakama hands RPC payloads over as a string. Decode defensively: a malformed -- body from a client must produce a clean error, never a Lua stack trace. function M.decode(payload) if payload == nil or payload == "" then return {} end local ok, decoded = pcall(nk.json_decode, payload) if not ok or type(decoded) ~= "table" then M.fail("invalid json payload") end return decoded end function M.encode(tbl) return nk.json_encode(tbl) end function M.ok(tbl) tbl = tbl or {} tbl.ok = true return nk.json_encode(tbl) end -- Raise an RPC error. -- -- The documented { msg, code } table form is NOT unwrapped by this Nakama -- build - it surfaces to the caller as the literal string "table: 0x...", -- which makes every failure undebuggable. A plain string message is worth more -- than a precise gRPC status code here, so throw the string. function M.fail(msg, _code) error(tostring(msg), 0) end function M.now() return os.time() end -- Guard for every rpc_gs_* endpoint. -- -- Two independent checks, because either alone is too weak: -- 1. ctx.user_id must be nil. A session-authenticated call always carries a -- user_id, so this proves the caller used the server-only http_key. -- 2. The payload must carry the shared secret from the GS_SHARED_SECRET env -- var. A leaked http_key is then still not enough to register a rogue -- game server or mint itself a ticket. function M.assert_gameserver(context, input) if context.user_id ~= nil and context.user_id ~= "" then M.fail("rpc is server-only", 7) end -- Nakama's Lua runtime exposes runtime.env on the context, not through a -- global getter. The value is injected via --runtime.env in docker-compose.yml. local expected = (context.env or {})["GS_SHARED_SECRET"] if expected == nil or expected == "" then -- Fail closed. A missing secret must never mean "allow everyone". nk.logger_error("GS_SHARED_SECRET is not set - refusing all game server rpcs") M.fail("server misconfigured", 13) end if input.secret ~= expected then nk.logger_warn("game server rpc rejected: bad shared secret") M.fail("unauthorized", 7) end end function M.assert_user(context) if context.user_id == nil or context.user_id == "" then M.fail("authentication required", 16) end return context.user_id end -- System-owned storage objects belong to the nil UUID. Passing an empty string -- for user_id is rejected outright by the storage layer - and because the write -- error surfaces through pcall rather than a return code, it fails *silently*. -- Always route system objects through this constant. M.SYSTEM_USER = "00000000-0000-0000-0000-000000000000" -- Read a single storage object, returning (value, version) or (nil, nil). function M.storage_read(collection, key, user_id) local objects = nk.storage_read({ { collection = collection, key = key, user_id = user_id or M.SYSTEM_USER } }) local obj = objects[1] if obj == nil then return nil, nil end return obj.value, obj.version end -- Write a single storage object. Pass version = "*" to require the key not to -- exist yet, or a concrete version to require it not to have changed since the -- read. Returns true on success, false if the version check lost the race. function M.storage_write(collection, key, user_id, value, version, permission_read) local write = { collection = collection, key = key, user_id = user_id or M.SYSTEM_USER, value = value, permission_read = permission_read or 0, -- 0 = no client read by default permission_write = 0, -- server-authoritative always } if version ~= nil then write.version = version end local ok, err = pcall(nk.storage_write, { write }) if not ok then -- A lost version check is expected and routine; anything else is a real -- fault and would otherwise vanish, since callers treat false as "retry". nk.logger_warn(("storage_write failed on %s/%s: %s") :format(collection, key, tostring(err))) return false end return true end function M.storage_delete(collection, key, user_id) pcall(nk.storage_delete, { { collection = collection, key = key, user_id = user_id or M.SYSTEM_USER } }) end function M.uuid() return nk.uuid_v4() end -- Resolve a mode from arbitrary client input, erroring on anything unknown. function M.require_mode(key) local mode = config.mode(key) if mode == nil then M.fail("unknown mode: " .. tostring(key), 3) end return mode end return M