/** * Tekton Nakama Server Runtime Module * * This module provides secure admin operations via RPC calls. * Deploy this to your Nakama server's runtime directory. */ // Initialize RPC endpoints function InitModule(ctx, logger, nk, initializer) { // Admin RPCs initializer.registerRpc("admin_kick_player", rpcAdminKickPlayer); initializer.registerRpc("admin_ban_player", rpcAdminBanPlayer); initializer.registerRpc("admin_unban_player", rpcAdminUnbanPlayer); initializer.registerRpc("admin_get_ban_list", rpcAdminGetBanList); initializer.registerRpc("admin_get_server_stats", rpcAdminGetServerStats); initializer.registerRpc("admin_get_player_list", rpcAdminGetPlayerList); initializer.registerRpc("admin_end_match", rpcAdminEndMatch); initializer.registerRpc("admin_set_user_role", rpcAdminSetUserRole); initializer.registerRpc("admin_list_users", rpcAdminListUsers); initializer.registerRpc("admin_delete_users", rpcAdminDeleteUsers); // User management RPCs initializer.registerRpc("get_user_profile", rpcGetUserProfile); initializer.registerRpc("update_user_profile", rpcUpdateUserProfile); // Leaderboard RPCs initializer.registerRpc("get_leaderboard_stats", rpcGetLeaderboardStats); initializer.registerRpc("admin_update_stats", rpcAdminUpdateStats); initializer.registerRpc("admin_delete_stats", rpcAdminDeleteStats); initializer.registerRpc("admin_sync_leaderboard", rpcAdminSyncLeaderboard); // Create default native leaderboard // id: "global_high_score", authoritative: true, sort: "desc", operator: "best", reset: None nk.leaderboardCreate("global_high_score", true, "desc", "best", null, {}); logger.info("Tekton admin module loaded"); } // ============================================================================= // Authorization Helpers // ============================================================================= var ADMIN_ROLES = ["admin", "moderator", "owner"]; function isAdmin(ctx, nk) { if (!ctx.userId) return false; try { var account = nk.accountGetId(ctx.userId); var metadata; if (typeof account.user.metadata === "string") { metadata = JSON.parse(account.user.metadata || "{}"); } else { metadata = account.user.metadata || {}; } var role = metadata.role || ""; return ADMIN_ROLES.indexOf(role) !== -1; } catch (e) { return false; } } function isMatchHost(ctx, nk, matchId) { if (!ctx.userId || !matchId) return false; try { // Get match state to check host var match = nk.matchGet(matchId); if (!match) return false; // The first user to join (presence) is typically the host // This logic may need adjustment based on your match handler var state = JSON.parse(match.state || "{}"); return state.hostUserId === ctx.userId; } catch (e) { return false; } } function requireAdmin(ctx, nk) { if (!isAdmin(ctx, nk)) { throw new Error("Admin privileges required"); } } function requireAdminOrHost(ctx, nk, matchId) { if (!isAdmin(ctx, nk) && !isMatchHost(ctx, nk, matchId)) { throw new Error("Admin or host privileges required"); } } // ============================================================================= // Admin RPCs // ============================================================================= function rpcAdminKickPlayer(ctx, logger, nk, payload) { var request = JSON.parse(payload); requireAdminOrHost(ctx, nk, request.match_id); // Can't kick yourself if (request.user_id === ctx.userId) { throw new Error("Cannot kick yourself"); } try { // Signal the match to kick the player nk.matchSignal(request.match_id, JSON.stringify({ action: "kick", user_id: request.user_id, reason: request.reason || "Kicked by admin" })); logger.info("Player " + request.user_id + " kicked from match " + request.match_id + " by " + ctx.userId); return JSON.stringify({ success: true }); } catch (e) { logger.error("Failed to kick player: " + e); throw new Error("Failed to kick player"); } } function rpcAdminBanPlayer(ctx, logger, nk, payload) { var request = JSON.parse(payload); // Only full admins can ban (not just match hosts) requireAdmin(ctx, nk); if (request.user_id === ctx.userId) { throw new Error("Cannot ban yourself"); } try { // Get target user's account var targetAccount = nk.accountGetId(request.user_id); var metadata = JSON.parse(targetAccount.user.metadata || "{}"); // Don't allow banning other admins if (ADMIN_ROLES.indexOf(metadata.role || "") !== -1) { throw new Error("Cannot ban an admin"); } // Set ban in metadata var banExpires = request.duration_hours && request.duration_hours > 0 ? new Date(Date.now() + request.duration_hours * 60 * 60 * 1000).toISOString() : null; metadata.banned = true; metadata.ban_reason = request.reason || "Banned by admin"; if (banExpires) { metadata.ban_expires = banExpires; } nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata)); // Also kick from current match if specified if (request.match_id) { nk.matchSignal(request.match_id, JSON.stringify({ action: "kick", user_id: request.user_id, reason: "Banned: " + (request.reason || "") })); } // Store in ban list (for quick lookup) var banRecord = { user_id: request.user_id, username: targetAccount.user.username, banned_by: ctx.userId, banned_at: new Date().toISOString(), reason: request.reason, expires: banExpires }; nk.storageWrite([{ collection: "bans", key: request.user_id, userId: "00000000-0000-0000-0000-000000000000", // System-owned value: banRecord, permissionRead: 2, // Public read permissionWrite: 0 // No one can write (except server) }]); logger.warn("Player " + request.user_id + " banned by " + ctx.userId + ". Reason: " + request.reason); return JSON.stringify({ success: true, ban: banRecord }); } catch (e) { logger.error("Failed to ban player: " + e); throw new Error("Failed to ban player: " + e); } } function rpcAdminUnbanPlayer(ctx, logger, nk, payload) { var request = JSON.parse(payload); requireAdmin(ctx, nk); try { // Get target user's account var targetAccount = nk.accountGetId(request.user_id); var metadata = JSON.parse(targetAccount.user.metadata || "{}"); // Remove ban delete metadata.banned; delete metadata.ban_reason; delete metadata.ban_expires; nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata)); // Remove from ban list nk.storageDelete([{ collection: "bans", key: request.user_id, userId: "00000000-0000-0000-0000-000000000000" }]); logger.info("Player " + request.user_id + " unbanned by " + ctx.userId); return JSON.stringify({ success: true }); } catch (e) { logger.error("Failed to unban player: " + e); throw new Error("Failed to unban player"); } } function rpcAdminGetBanList(ctx, logger, nk, payload) { requireAdmin(ctx, nk); try { var result = nk.storageList( "00000000-0000-0000-0000-000000000000", "bans", 100, "" ); var bans = result.objects ? result.objects.map(function(obj) { return obj.value; }) : []; return JSON.stringify({ bans: bans }); } catch (e) { logger.error("Failed to get ban list: " + e); return JSON.stringify({ bans: [] }); } } function rpcAdminGetServerStats(ctx, logger, nk, payload) { var request = JSON.parse(payload || "{}"); if (request.match_id) { requireAdminOrHost(ctx, nk, request.match_id); } else { requireAdmin(ctx, nk); } try { // Get server-wide stats var matches = nk.matchList(100, true, null, null, null, null); var activeMatchCount = matches ? matches.length : 0; var totalPlayers = 0; if (matches) { for (var i = 0; i < matches.length; i++) { totalPlayers += matches[i].size || 0; } } var stats = { active_matches: activeMatchCount, total_players: totalPlayers, server_time: new Date().toISOString() }; // If specific match requested, include match details if (request.match_id) { try { var match = nk.matchGet(request.match_id); if (match) { stats.match = { id: match.matchId, size: match.size, tick_rate: match.tickRate, authoritative: match.authoritative }; } } catch (e) { // Match not found } } return JSON.stringify(stats); } catch (e) { logger.error("Failed to get server stats: " + e); throw new Error("Failed to get server stats"); } } function rpcAdminGetPlayerList(ctx, logger, nk, payload) { var request = JSON.parse(payload); requireAdminOrHost(ctx, nk, request.match_id); try { var match = nk.matchGet(request.match_id); if (!match) { throw new Error("Match not found"); } // Get player details var players = []; // Note: In actual implementation, you'd need to track presences // This is a simplified version - adjust based on your match handler return JSON.stringify({ players: players }); } catch (e) { logger.error("Failed to get player list: " + e); throw new Error("Failed to get player list"); } } function rpcAdminEndMatch(ctx, logger, nk, payload) { var request = JSON.parse(payload); requireAdminOrHost(ctx, nk, request.match_id); try { // Signal match to end nk.matchSignal(request.match_id, JSON.stringify({ action: "end_match", reason: request.reason || "Ended by admin" })); logger.info("Match " + request.match_id + " ended by " + ctx.userId); return JSON.stringify({ success: true }); } catch (e) { logger.error("Failed to end match: " + e); throw new Error("Failed to end match"); } } function rpcAdminSetUserRole(ctx, logger, nk, payload) { var request = JSON.parse(payload); // Only owner/super-admin can set roles var callerAccount = nk.accountGetId(ctx.userId); var callerMetadata = JSON.parse(callerAccount.user.metadata || "{}"); if (callerMetadata.role !== "owner") { throw new Error("Only owners can modify user roles"); } var validRoles = ["player", "moderator", "admin"]; if (validRoles.indexOf(request.role) === -1) { throw new Error("Invalid role"); } try { var targetAccount = nk.accountGetId(request.user_id); var metadata = JSON.parse(targetAccount.user.metadata || "{}"); metadata.role = request.role; nk.accountUpdateId(request.user_id, null, null, null, null, null, null, JSON.stringify(metadata)); logger.info("User " + request.user_id + " role set to " + request.role + " by " + ctx.userId); return JSON.stringify({ success: true, role: request.role }); } catch (e) { logger.error("Failed to set user role: " + e); throw new Error("Failed to set user role"); } } // ============================================================================= // User Profile RPCs // ============================================================================= function rpcGetUserProfile(ctx, logger, nk, payload) { var request = JSON.parse(payload || "{}"); var targetUserId = request.user_id || ctx.userId; try { var account = nk.accountGetId(targetUserId); var metadata = JSON.parse(account.user.metadata || "{}"); // Check if banned if (metadata.banned && targetUserId === ctx.userId) { // Check if ban expired if (metadata.ban_expires) { var expiresAt = new Date(metadata.ban_expires); if (expiresAt <= new Date()) { // Ban expired, remove it delete metadata.banned; delete metadata.ban_reason; delete metadata.ban_expires; nk.accountUpdateId(targetUserId, null, null, null, null, null, null, JSON.stringify(metadata)); } else { throw new Error("Account banned until " + metadata.ban_expires + ". Reason: " + metadata.ban_reason); } } else { throw new Error("Account permanently banned. Reason: " + metadata.ban_reason); } } return JSON.stringify({ user_id: account.user.id, username: account.user.username, display_name: account.user.displayName, avatar_url: account.user.avatarUrl, create_time: account.user.createTime, role: metadata.role || "player" }); } catch (e) { throw e; } } function rpcUpdateUserProfile(ctx, logger, nk, payload) { if (!ctx.userId) { throw new Error("Not authenticated"); } var request = JSON.parse(payload); try { nk.accountUpdateId( ctx.userId, null, // username request.display_name || null, null, // timezone null, // location null, // lang request.avatar_url || null, null // metadata ); return JSON.stringify({ success: true }); } catch (e) { logger.error("Failed to update profile: " + e); throw new Error("Failed to update profile"); } } // ============================================================================= // Leaderboard RPCs // ============================================================================= function rpcGetLeaderboardStats(ctx, logger, nk, payload) { try { var limit = 50; var records = nk.leaderboardRecordsList("global_high_score", null, limit, ""); var leaderboardData = []; var ownerRecords = records.records || []; for (var i = 0; i < ownerRecords.length; i++) { var record = ownerRecords[i]; var metadata = {}; try { metadata = JSON.parse(record.metadata || "{}"); } catch (e) {} leaderboardData.push({ user_id: record.ownerId, username: record.username, display_name: record.displayName || record.username, avatar_url: metadata.avatar_url || "", high_score: record.score || 0, games_played: metadata.games_played || 0, games_won: metadata.games_won || 0 }); } return JSON.stringify({ leaderboard: leaderboardData }); } catch (e) { logger.error("Failed to get native leaderboard stats: " + e); // Fallback to storage-based if native fails (e.g. not created yet) return JSON.stringify({ leaderboard: [] }); } } // ============================================================================= // Admin User Management RPCs // ============================================================================= function rpcAdminListUsers(ctx, logger, nk, payload) { requireAdmin(ctx, nk); try { var users = []; var rows = nk.sqlQuery("SELECT id, username, display_name, metadata, create_time FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 500", []); for (var i = 0; i < rows.length; i++) { var row = rows[i]; var metadata = {}; try { metadata = JSON.parse(row.metadata || "{}"); } catch(e) {} users.push({ user_id: row.id, username: row.username || "", display_name: row.display_name || row.username || "", create_time: row.create_time, role: metadata.role || "player", banned: metadata.banned || false, ban_reason: metadata.ban_reason || "" }); } return JSON.stringify({ users: users, count: users.length }); } catch (e) { logger.error("Failed to list users: " + e); throw new Error("Failed to list users: " + e); } } function rpcAdminDeleteUsers(ctx, logger, nk, payload) { requireAdmin(ctx, nk); var request = JSON.parse(payload); var userIds = request.user_ids || []; if (userIds.length === 0) { throw new Error("No user IDs provided"); } // Don't allow deleting yourself for (var i = 0; i < userIds.length; i++) { if (userIds[i] === ctx.userId) { throw new Error("Cannot delete your own account"); } } var deleted = []; var failed = []; for (var j = 0; j < userIds.length; j++) { var uid = userIds[j]; try { // Check if target is admin — don't allow deleting admins var account = nk.accountGetId(uid); var meta = {}; try { meta = JSON.parse(account.user.metadata || "{}"); } catch(e) {} if (ADMIN_ROLES.indexOf(meta.role || "") !== -1) { failed.push({ user_id: uid, reason: "Cannot delete admin account" }); continue; } nk.accountDeleteId(uid, false); deleted.push(uid); logger.warn("User " + uid + " deleted by " + ctx.userId); } catch (e) { failed.push({ user_id: uid, reason: "" + e }); } } return JSON.stringify({ success: true, deleted: deleted, failed: failed }); } function rpcAdminUpdateStats(ctx, logger, nk, payload) { requireAdmin(ctx, nk); var request = JSON.parse(payload); var targetUserId = request.user_id; var stats = request.stats; // { high_score: X, games_played: Y, games_won: Z } if (!targetUserId || !stats) { throw new Error("User ID and stats are required"); } try { // 1. Update Storage (for legacy support/redundancy) nk.storageWrite([{ collection: "stats", key: "stats", userId: targetUserId, value: JSON.stringify(stats), permissionRead: 1, // Public read permissionWrite: 0 // No one can write (except server) }]); // 2. Update Native Leaderboard var account = nk.accountGetId(targetUserId); var score = stats.high_score || 0; var subscore = 0; var metadata = { games_played: stats.games_played || 0, games_won: stats.games_won || 0, avatar_url: account.user.avatarUrl || "" }; nk.leaderboardRecordWrite("global_high_score", targetUserId, account.user.username, score, subscore, JSON.stringify(metadata)); logger.info("Stats updated for user " + targetUserId + " by admin " + ctx.userId + " (Storage + Native)"); return JSON.stringify({ success: true }); } catch (e) { logger.error("Failed to update stats: " + e); throw new Error("Failed to update stats: " + e); } } function rpcAdminDeleteStats(ctx, logger, nk, payload) { requireAdmin(ctx, nk); var request = JSON.parse(payload); var targetUserId = request.user_id; if (!targetUserId) { throw new Error("User ID is required"); } try { // 1. Delete Storage nk.storageDelete([{ collection: "stats", key: "stats", userId: targetUserId }]); // 2. Delete Native Leaderboard Record nk.leaderboardRecordDelete("global_high_score", targetUserId); logger.info("Stats deleted for user " + targetUserId + " by admin " + ctx.userId); return JSON.stringify({ success: true }); } catch (e) { logger.error("Failed to delete stats: " + e); throw new Error("Failed to delete stats"); } } function rpcAdminSyncLeaderboard(ctx, logger, nk, payload) { requireAdmin(ctx, nk); try { var result = nk.storageList(null, "stats", 100, ""); var statsObjects = result.objects || []; var count = 0; for (var i = 0; i < statsObjects.length; i++) { var obj = statsObjects[i]; var userId = obj.userId; var stats = JSON.parse(obj.value); try { var account = nk.accountGetId(userId); var metadata = { games_played: stats.games_played || 0, games_won: stats.games_won || 0, avatar_url: account.user.avatarUrl || "" }; nk.leaderboardRecordWrite("global_high_score", userId, account.user.username, stats.high_score || 0, 0, JSON.stringify(metadata)); count++; } catch (inner) { logger.error("Failed to sync record for " + userId + ": " + inner); } } return JSON.stringify({ success: true, synced: count }); } catch (e) { logger.error("Leaderboard sync failed: " + e); throw new Error("Sync failed: " + e); } } // Before login hook to check ban status function beforeAuthenticateEmail(ctx, logger, nk, data) { // Can't check ban before auth, so we check in afterAuthenticate return data; }