/** * Tekton Nakama Server Runtime Module * * This module provides secure admin operations via RPC calls. * Deploy this to your Nakama server's runtime directory. * * For TypeScript modules, compile to JavaScript and place in: * - data/modules/ (for Nakama Docker) * - Or configure in nakama config.yml */ // Initialize RPC endpoints function InitModule( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.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); logger.info("Tekton admin module loaded"); } // ============================================================================= // Authorization Helpers // ============================================================================= interface UserMetadata { role?: string; banned?: boolean; ban_reason?: string; ban_expires?: string; } const ADMIN_ROLES = ["admin", "moderator", "owner"]; function isAdmin(ctx: nkruntime.Context, nk: nkruntime.Nakama): boolean { if (!ctx.userId) return false; try { const account = nk.accountGetId(ctx.userId); const metadata = JSON.parse(account.user.metadata || "{}") as UserMetadata; return ADMIN_ROLES.includes(metadata.role || ""); } catch (e) { return false; } } function isMatchHost( ctx: nkruntime.Context, nk: nkruntime.Nakama, matchId: string ): boolean { if (!ctx.userId || !matchId) return false; try { // Get match state to check host const 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 const state = JSON.parse(match.state || "{}"); return state.hostUserId === ctx.userId; } catch (e) { return false; } } function requireAdmin(ctx: nkruntime.Context, nk: nkruntime.Nakama): void { if (!isAdmin(ctx, nk)) { throw new Error("Admin privileges required"); } } function requireAdminOrHost( ctx: nkruntime.Context, nk: nkruntime.Nakama, matchId: string ): void { if (!isAdmin(ctx, nk) && !isMatchHost(ctx, nk, matchId)) { throw new Error("Admin or host privileges required"); } } // ============================================================================= // Admin RPCs // ============================================================================= interface KickPlayerRequest { match_id: string; user_id: string; reason?: string; } function rpcAdminKickPlayer( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { const request = JSON.parse(payload) as KickPlayerRequest; 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"); } } interface BanPlayerRequest { user_id: string; reason?: string; duration_hours?: number; // 0 = permanent match_id?: string; // Optional: also kick from current match } function rpcAdminBanPlayer( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { const request = JSON.parse(payload) as BanPlayerRequest; // 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 const targetAccount = nk.accountGetId(request.user_id); const metadata = JSON.parse(targetAccount.user.metadata || "{}") as UserMetadata; // Don't allow banning other admins if (ADMIN_ROLES.includes(metadata.role || "")) { throw new Error("Cannot ban an admin"); } // Set ban in metadata const 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) const 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}`); } } interface UnbanPlayerRequest { user_id: string; } function rpcAdminUnbanPlayer( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { const request = JSON.parse(payload) as UnbanPlayerRequest; requireAdmin(ctx, nk); try { // Get target user's account const targetAccount = nk.accountGetId(request.user_id); const metadata = JSON.parse(targetAccount.user.metadata || "{}") as UserMetadata; // 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: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { requireAdmin(ctx, nk); try { const result = nk.storageList( "00000000-0000-0000-0000-000000000000", "bans", 100, "" ); const bans = result.objects?.map(obj => obj.value) || []; return JSON.stringify({ bans }); } catch (e) { logger.error(`Failed to get ban list: ${e}`); return JSON.stringify({ bans: [] }); } } interface GetServerStatsRequest { match_id?: string; } function rpcAdminGetServerStats( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { const request = JSON.parse(payload || "{}") as GetServerStatsRequest; if (request.match_id) { requireAdminOrHost(ctx, nk, request.match_id); } else { requireAdmin(ctx, nk); } try { // Get server-wide stats const matches = nk.matchList(100, true, null, null, null, null); const activeMatchCount = matches?.length || 0; let totalPlayers = 0; if (matches) { for (const match of matches) { totalPlayers += match.size || 0; } } const stats = { active_matches: activeMatchCount, total_players: totalPlayers, server_time: new Date().toISOString() }; // If specific match requested, include match details if (request.match_id) { try { const match = nk.matchGet(request.match_id); if (match) { (stats as any).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"); } } interface GetPlayerListRequest { match_id: string; } function rpcAdminGetPlayerList( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { const request = JSON.parse(payload) as GetPlayerListRequest; requireAdminOrHost(ctx, nk, request.match_id); try { const match = nk.matchGet(request.match_id); if (!match) { throw new Error("Match not found"); } // Get player details const players: any[] = []; // 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 }); } catch (e) { logger.error(`Failed to get player list: ${e}`); throw new Error("Failed to get player list"); } } interface EndMatchRequest { match_id: string; reason?: string; } function rpcAdminEndMatch( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { const request = JSON.parse(payload) as EndMatchRequest; 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"); } } interface SetUserRoleRequest { user_id: string; role: string; // "player", "moderator", "admin" } function rpcAdminSetUserRole( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { const request = JSON.parse(payload) as SetUserRoleRequest; // Only owner/super-admin can set roles const callerAccount = nk.accountGetId(ctx.userId!); const callerMetadata = JSON.parse(callerAccount.user.metadata || "{}"); if (callerMetadata.role !== "owner") { throw new Error("Only owners can modify user roles"); } const validRoles = ["player", "moderator", "admin"]; if (!validRoles.includes(request.role)) { throw new Error("Invalid role"); } try { const targetAccount = nk.accountGetId(request.user_id); const 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: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { const request = JSON.parse(payload || "{}"); const targetUserId = request.user_id || ctx.userId; try { const account = nk.accountGetId(targetUserId!); const metadata = JSON.parse(account.user.metadata || "{}"); // Check if banned if (metadata.banned && targetUserId === ctx.userId) { // Check if ban expired if (metadata.ban_expires) { const 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; } } interface UpdateProfileRequest { display_name?: string; avatar_url?: string; } function rpcUpdateUserProfile( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { if (!ctx.userId) { throw new Error("Not authenticated"); } const request = JSON.parse(payload) as UpdateProfileRequest; 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: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { try { // Query the "stats" collection to get all user stats // Warning: For a large production game this should be indexed using Nakama's actual Leaderboard system, // but this works for listing all users' custom storage stats. const limit = 100; const result = nk.storageList(null, "stats", limit, ""); const statsObjects = result.objects || []; const leaderboardData: any[] = []; for (const obj of statsObjects) { try { const stats = JSON.parse(obj.value); const userId = obj.userId; // Get the user's profile to retrieve their display name let displayName = "Unknown"; let avatarUrl = ""; try { const account = nk.accountGetId(userId); displayName = account.user.displayName || account.user.username; avatarUrl = account.user.avatarUrl; } catch (e) { // Ignore if account fetch fails } leaderboardData.push({ user_id: userId, display_name: displayName, avatar_url: avatarUrl, games_played: stats.games_played || 0, games_won: stats.games_won || 0, high_score: stats.high_score || 0 }); } catch (e) { logger.error(`Error parsing stats for object: ${obj.key}`); } } return JSON.stringify({ leaderboard: leaderboardData }); } catch (e) { logger.error(`Failed to get leaderboard stats: ${e}`); throw new Error("Failed to get leaderboard stats"); } } // ============================================================================= // Admin User Management RPCs // ============================================================================= function rpcAdminListUsers( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { requireAdmin(ctx, nk); try { const users: any[] = []; let cursor = ""; for (let page = 0; page < 10; page++) { const result = nk.usersList("", "", 100, cursor); if (!result || !result.users || result.users.length === 0) break; for (const u of result.users) { let metadata: UserMetadata = {}; try { metadata = JSON.parse(u.metadata || "{}"); } catch(e) {} users.push({ user_id: u.id, username: u.username || "", display_name: u.displayName || u.username || "", create_time: u.createTime, role: metadata.role || "player", banned: metadata.banned || false, ban_reason: metadata.ban_reason || "" }); } if (!result.cursor) break; cursor = result.cursor; } return JSON.stringify({ users, count: users.length }); } catch (e) { logger.error(`Failed to list users: ${e}`); throw new Error(`Failed to list users: ${e}`); } } interface DeleteUsersRequest { user_ids: string[]; } function rpcAdminDeleteUsers( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string ): string { requireAdmin(ctx, nk); const request = JSON.parse(payload) as DeleteUsersRequest; const userIds = request.user_ids || []; if (userIds.length === 0) { throw new Error("No user IDs provided"); } for (const uid of userIds) { if (uid === ctx.userId) { throw new Error("Cannot delete your own account"); } } const deleted: string[] = []; const failed: { user_id: string; reason: string }[] = []; for (const uid of userIds) { try { const account = nk.accountGetId(uid); const meta = JSON.parse(account.user.metadata || "{}") as UserMetadata; if (ADMIN_ROLES.includes(meta.role || "")) { 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, failed }); } // Before login hook to check ban status function beforeAuthenticateEmail( ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, data: nkruntime.AuthenticateEmailRequest ): nkruntime.AuthenticateEmailRequest { // Can't check ban before auth, so we check in afterAuthenticate return data; }