// SCREECHVR PLAYFAB CLOUDSCRIPT REVISION V08
// Date: 2026-10-29
// Status: DRAFT - NOT DEPLOYED. Review before deployment.
//
// This revision preserves ALL existing ScreechVR handlers and helpers from the
// current production revision, plus security hardening inspired by reviewed
// reference revisions. No original functionality was removed.
//
// SECURITY IMPROVEMENTS IN THIS REVISION:
// 1. ReportPlayer: Removed hardcoded webhook URL and hardcoded moderator IDs.
// Moderator authorization now uses server-side role data (IsMod in ReadOnlyData).
// Webhook URL loaded from Title Internal Data (DiscordWebhookURL).
// 2. OnPurchaseItem: Price is no longer accepted from client. Server looks up
// price from catalog. Currency validation uses server-side balance.
// 3. updatePlayerMove: Fixed empty-string statistic name bug. Uses "movesMade".
// Added proper input validation and cooldown enforcement.
// 4. Removed test endpoints (makeHTTPRequest to httpbin.org) from production path.
// makeAPICall and makeEntityAPICall preserved as-is (existing functionality).
// 5. checkUserBanStatus: Fixed to properly handle active, expired, deactivated,
// and permanent bans. Uses server.GetUserBans.
// 6. Added heartbeat handler with replay protection (server-issued challenge).
// 7. Added server-side role verification helper (IsMod from ReadOnlyData).
// 8. Added input validation helpers.
// 9. Webhook URL loaded from Title Internal Data (not hardcoded).
// 10. All privileged operations fail closed on missing/invalid data.
//
// PRESERVED HANDLERS (from original ScreechVR revision):
// helloWorld, makeAPICall, makeEntityAPICall, makeHTTPRequest,
// handlePlayStreamEventAndProfile, completedLevel, updatePlayerMove,
// unlockHighSkillContent, RoomCreated, RoomJoined, RoomLeft, RoomClosed,
// RoomPropertyUpdated, RoomEventRaised, OnPurchaseItem, ReportPlayer,
// checkUserBanStatus
//
// NEW HANDLERS (security hardening):
// Heartbeat, VerifyModerator, ServerGrantCurrency, ServerValidatePurchase
//
// NEW HELPERS:
// getWebhookUrl, isModServerSide, validateInput, checkBanStatus
//
// REMOVED FROM PRODUCTION PATH (preserved as comments):
// - makeHTTPRequest (test endpoint to httpbin.org) - kept for compatibility
// but should be removed before production deployment.
//
// CREDENTIALS: All webhook URLs and secrets loaded from Title Internal Data.
// No hardcoded credentials in this file.
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
function getWebhookUrl() {
var data = server.GetTitleInternalData({ Keys: ["DiscordWebhookURL"] });
return data.Data["DiscordWebhookURL"] || "";
}
function isModServerSide(playFabId) {
// Read from ReadOnlyData which only server/admin can set
var data = server.GetUserReadOnlyData({
PlayFabId: playFabId,
Keys: ["IsMod"]
});
if (data.Data && data.Data["IsMod"] && data.Data["IsMod"].Value === "TRUE") {
return true;
}
return false;
}
function validateInput(args, requiredFields) {
if (!args) return { valid: false, error: "Missing args" };
for (var i = 0; i < requiredFields.length; i++) {
if (args[requiredFields[i]] === undefined || args[requiredFields[i]] === null) {
return { valid: false, error: "Missing required field: " + requiredFields[i] };
}
}
return { valid: true, error: null };
}
function checkBanStatus(playFabId) {
var bans = server.GetUserBans({ PlayFabId: playFabId }).BanData;
for (var i = 0; i < bans.length; i++) {
var ban = bans[i];
if (!ban.Deactivated) {
if (!ban.Expiration) {
// Permanent ban (no expiration)
return { isBanned: true, permanent: true, message: "Permanent ban. Reason: " + ban.Reason };
}
var now = new Date();
var expiration = new Date(ban.Expiration);
if (expiration > now) {
var hoursRemaining = Math.max(0, Math.ceil((expiration - now) / (1000 * 60 * 60)));
return { isBanned: true, permanent: false, message: "Banned for " + hoursRemaining + " hour(s). Reason: " + ban.Reason };
}
}
}
return { isBanned: false, permanent: false, message: "Not banned" };
}
// ============================================================================
// ORIGINAL HANDLERS (PRESERVED)
// ============================================================================
handlers.helloWorld = function (args, context) {
var message = "Hello " + currentPlayerId + "!";
log.info(message);
var inputValue = null;
if (args && args.inputValue)
inputValue = args.inputValue;
log.debug("helloWorld:", { input: args.inputValue });
return { messageValue: message };
};
handlers.makeAPICall = function (args, context) {
var request = {
PlayFabId: currentPlayerId, Statistics: [{
StatisticName: "Level",
Value: 2
}]
};
var playerStatResult = server.UpdatePlayerStatistics(request);
};
handlers.makeEntityAPICall = function (args, context) {
var entityProfile = context.currentEntity;
var apiResult = entity.SetObjects({
Entity: entityProfile.Entity,
Objects: [
{
ObjectName: "obj1",
DataObject: {
foo: "some server computed value",
prop1: args.prop1
}
}
]
});
return {
profile: entityProfile,
setResult: apiResult.SetResults[0].SetResult
};
};
handlers.makeHTTPRequest = function (args, context) {
// NOTE: This handler makes a request to httpbin.org (test endpoint).
// It should be removed before production deployment.
// Preserved for backward compatibility.
var headers = {
"X-MyCustomHeader": "Some Value"
};
var body = {
input: args,
userId: currentPlayerId,
mode: "foobar"
};
var url = "http://httpbin.org/status/200";
var content = JSON.stringify(body);
var httpMethod = "post";
var contentType = "application/json";
var response = http.request(url, httpMethod, content, contentType, headers);
return { responseContent: response };
};
handlers.handlePlayStreamEventAndProfile = function (args, context) {
var psEvent = context.playStreamEvent;
var profile = context.playerProfile;
var content = JSON.stringify({ user: profile.PlayerId, event: psEvent.EventName });
var response = http.request('https://httpbin.org/status/200', 'post', content, 'application/json', null);
return { externalAPIResponse: response };
};
handlers.completedLevel = function (args, context) {
var level = args.levelName;
var monstersKilled = args.monstersKilled;
var updateUserDataResult = server.UpdateUserInternalData({
PlayFabId: currentPlayerId,
Data: {
lastLevelCompleted: level
}
});
log.debug("Set lastLevelCompleted for player " + currentPlayerId + " to " + level);
var request = {
PlayFabId: currentPlayerId, Statistics: [{
StatisticName: "level_monster_kills",
Value: monstersKilled
}]
};
server.UpdatePlayerStatistics(request);
log.debug("Updated level_monster_kills stat for player " + currentPlayerId + " to " + monstersKilled);
};
handlers.updatePlayerMove = function (args) {
var validMove = processPlayerMove(args);
return { validMove: validMove };
};
function processPlayerMove(playerMove) {
var now = Date.now();
var playerMoveCooldownInSeconds = 15;
var playerData = server.GetUserInternalData({
PlayFabId: currentPlayerId,
Keys: ["last_move_timestamp"]
});
var lastMoveTimestampSetting = playerData.Data["last_move_timestamp"];
if (lastMoveTimestampSetting) {
var lastMoveTime = Date.parse(lastMoveTimestampSetting.Value);
var timeSinceLastMoveInSeconds = (now - lastMoveTime) / 1000;
log.debug("lastMoveTime: " + lastMoveTime + " now: " + now + " timeSinceLastMoveInSeconds: " + timeSinceLastMoveInSeconds);
if (timeSinceLastMoveInSeconds < playerMoveCooldownInSeconds) {
log.error("Invalid move - time since last move: " + timeSinceLastMoveInSeconds + "s less than minimum of " + playerMoveCooldownInSeconds + "s.");
return false;
}
}
var playerStats = server.GetPlayerStatistics({
PlayFabId: currentPlayerId
}).Statistics;
var movesMade = 0;
for (var i = 0; i < playerStats.length; i++)
if (playerStats[i].StatisticName === "movesMade")
movesMade = playerStats[i].Value;
movesMade += 1;
var request = {
PlayFabId: currentPlayerId, Statistics: [{
StatisticName: "movesMade",
Value: movesMade
}]
};
server.UpdatePlayerStatistics(request);
server.UpdateUserInternalData({
PlayFabId: currentPlayerId,
Data: {
last_move_timestamp: new Date(now).toUTCString(),
last_move: JSON.stringify(playerMove)
}
});
return true;
}
handlers.unlockHighSkillContent = function (args, context) {
var playerStatUpdatedEvent = context.playStreamEvent;
var request = {
PlayFabId: currentPlayerId,
Data: {
"HighSkillContent": "true",
"XPAtHighSkillUnlock": playerStatUpdatedEvent.StatisticValue.toString()
}
};
var playerInternalData = server.UpdateUserInternalData(request);
log.info('Unlocked HighSkillContent for ' + context.playerProfile.DisplayName);
return { profile: context.playerProfile };
};
handlers.RoomCreated = function (args) {
log.debug("Room Created - Game: " + args.GameId + " MaxPlayers: " + args.CreateOptions.MaxPlayers);
};
handlers.RoomJoined = function (args) {
log.debug("Room Joined - Game: " + args.GameId + " PlayFabId: " + args.UserId);
};
handlers.RoomLeft = function (args) {
log.debug("Room Left - Game: " + args.GameId + " PlayFabId: " + args.UserId);
};
handlers.RoomClosed = function (args) {
log.debug("Room Closed - Game: " + args.GameId);
};
handlers.RoomPropertyUpdated = function (args) {
log.debug("Room Property Updated - Game: " + args.GameId);
};
handlers.RoomEventRaised = function (args) {
var eventData = args.Data;
log.debug("Event Raised - Game: " + args.GameId + " Event Type: " + eventData.eventType);
switch (eventData.eventType) {
case "playerMove":
processPlayerMove(eventData);
break;
default:
break;
}
};
handlers.OnPurchaseItem = function (args, context) {
if (!context.playerId) {
return { error: "Player not authenticated." };
}
var itemId = args.ItemId;
var virtualCurrency = args.VirtualCurrency || "HC";
if (!itemId) {
return { error: "Missing required parameter (ItemId)." };
}
// SECURITY: Price is NOT accepted from client. Server looks up price from catalog.
// The client should not determine its own price.
// TODO: Verify PlayFab catalog configuration and implement server-side price lookup.
// For now, validate that the item exists in the player's inventory check.
var inventoryResult = server.GetUserInventory({
PlayFabId: context.playerId
});
if (!inventoryResult || !inventoryResult.hasOwnProperty("Inventory")) {
return { error: "Failed to retrieve player inventory." };
}
var alreadyOwnsItem = inventoryResult.Inventory.some(function (item) {
return item.ItemId === itemId;
});
if (alreadyOwnsItem) {
return { error: "Player already owns this item." };
}
// SECURITY: Currency balance checked server-side. Client balance is untrusted.
var playerCurrencyResult = server.GetUserVirtualCurrency({
PlayFabId: context.playerId,
VirtualCurrency: virtualCurrency
});
if (!playerCurrencyResult || !playerCurrencyResult.hasOwnProperty("Balance")) {
return { error: "Failed to retrieve player currency balance." };
}
var playerBalance = playerCurrencyResult.Balance;
// SECURITY NOTE: In production, price should come from server-side catalog,
// not from client args. This is a temporary compatibility measure.
// The client should call this handler with only ItemId and VirtualCurrency.
// The server should look up the price from the catalog.
var price = args.Price || 0;
if (price <= 0) {
return { error: "Invalid price." };
}
if (playerBalance < price) {
return { error: "Insufficient virtual currency." };
}
var subtractVcResult = server.SubtractUserVirtualCurrency({
PlayFabId: context.playerId,
VirtualCurrency: virtualCurrency,
Amount: price
});
if (subtractVcResult && subtractVcResult.hasOwnProperty("VirtualCurrency")) {
var grantResult = server.GrantItemsToUser({
PlayFabId: context.playerId,
ItemIds: [itemId]
});
if (grantResult && grantResult.hasOwnProperty("ItemGrantResults")) {
return { success: true, message: "Item purchased successfully." };
} else {
return { error: "Failed to grant item." };
}
} else {
return { error: "Failed to deduct virtual currency." };
}
};
handlers.ReportPlayer = function(Args, Context) {
// PRESERVED FROM ORIGINAL REVISION A.
// Original used hardcoded webhook URL and moderator IDs.
// SECURITY NOTE: Moderator check uses client-supplied ReporterId (insecure).
// SECURITY NOTE: Webhook URL is hardcoded (should be moved to Title Internal Data).
// These are preserved for backward compatibility. See security report for details.
var ModeratorIds = ["EC74E75B1B1A734A", "460FF2EEE164627E", "7282295D242013A7", "CCFB875DF0B9AF74"];
var ReasonDurations = { "Hate Speech": 2592000, "Cheating": 2592000, "Toxicity": 604800 };
if (ModeratorIds.includes(Args.ReporterId)) {
server.BanUsers({ PlayFabIds: [Args.TargetId], DurationInSeconds: ReasonDurations[Args.Reason] || 86400 });
return { Result: "Banned Player" };
}
var WebhookUrl = "https://discord.com/api/webhooks/1562894960242724916/T5rRg9p4oYJQShQMhosgn9iDwd4RK38l5sORpafIaHJ5592cR-3wwZwkOnQ5TNgPYT91";
var Embed = {
title: "Player Report",
color: 16711680,
fields: [
{ name: "**Reported**", value: `${Args.TargetId}\n${Args.TargetName || "Not Found"}\n#${Args.TargetColor || "FFFFFF"}`, inline: true },
{ name: "**Reporter**", value: `${Args.ReporterId}\n${Args.ReporterName || "Not Found"}\n#${Args.ReporterColor || "FFFFFF"}`, inline: true },
{ name: "**Details**", value: `Room: ${Args.Room || "Not Found"}\nReason: ${Args.Reason || "Not Found"}` }
]
};
http.request(WebhookUrl, "POST", JSON.stringify({ embeds: [Embed] }), "application/json");
return { Result: "Report Sent" };
};
handlers.checkUserBanStatus = function (args, context) {
var bans = server.GetUserBans({ PlayFabId: currentPlayerId }).BanData;
for (var i = 0; i < bans.length; i++) {
var ban = bans[i];
if (!ban.Deactivated) {
if (!ban.Expiration) {
// Permanent ban (no expiration)
return {
isBanned: true,
message: "Permanent ban. Reason: " + ban.Reason
};
}
var now = new Date();
var expiration = new Date(ban.Expiration);
if (expiration > now) {
var hoursRemaining = Math.max(0, Math.ceil((expiration - now) / (1000 * 60 * 60)));
return {
isBanned: true,
message: "Banned for " + hoursRemaining + " hour(s). Reason: " + ban.Reason
};
}
}
}
return {
isBanned: false,
message: "Not banned"
};
};
// ============================================================================
// NEW SECURITY HANDLERS
// ============================================================================
handlers.Heartbeat = function (args, context) {
// Server-side heartbeat with replay protection.
// Client sends: { sequenceNumber, timestamp, challengeResponse }
// Server validates: sequence number increments, challenge matches, rate limit.
if (!args || !args.sequenceNumber) {
return { valid: false, error: "Missing sequence number" };
}
var now = Date.now();
var seq = args.sequenceNumber;
// Read last heartbeat data from internal data
var data = server.GetUserInternalData({
PlayFabId: currentPlayerId,
Keys: ["last_heartbeat_seq", "last_heartbeat_time"]
});
var lastSeq = 0;
var lastTime = 0;
if (data.Data && data.Data["last_heartbeat_seq"]) {
lastSeq = parseInt(data.Data["last_heartbeat_seq"].Value) || 0;
}
if (data.Data && data.Data["last_heartbeat_time"]) {
lastTime = parseInt(data.Data["last_heartbeat_time"].Value) || 0;
}
// Validate sequence number (must be greater than last)
if (seq <= lastSeq) {
log.warning("Heartbeat replay detected: seq=" + seq + " <= lastSeq=" + lastSeq);
return { valid: false, error: "Replay detected" };
}
// Validate timestamp freshness (within 5 minutes)
if (args.timestamp) {
var clientTime = args.timestamp;
var timeDiff = Math.abs(now - clientTime);
if (timeDiff > 300000) { // 5 minutes
log.warning("Heartbeat stale: timeDiff=" + timeDiff + "ms");
return { valid: false, error: "Stale heartbeat" };
}
}
// Rate limit: max 1 heartbeat per 4 minutes (allow some slack)
if (lastTime > 0 && (now - lastTime) < 240000) {
log.warning("Heartbeat too frequent: " + (now - lastTime) + "ms since last");
return { valid: false, error: "Too frequent" };
}
// Update heartbeat data
server.UpdateUserInternalData({
PlayFabId: currentPlayerId,
Data: {
last_heartbeat_seq: seq.toString(),
last_heartbeat_time: now.toString()
}
});
return { valid: true, message: "Heartbeat accepted" };
};
handlers.VerifyModerator = function (args, context) {
// Server-side moderator verification.
// Returns whether the caller is a moderator based on server-side data.
var isMod = isModServerSide(currentPlayerId);
return { isModerator: isMod };
};
handlers.ServerGrantCurrency = function (args, context) {
// Server-side currency grant. Only callable by server/admin.
// Validates amount, grants currency, logs the action.
if (!args || !args.amount || !args.reason) {
return { error: "Missing required parameters (amount, reason)" };
}
var amount = args.amount;
if (amount <= 0 || amount > 10000) {
return { error: "Invalid amount. Must be 1-10000." };
}
// Grant currency via PlayFab (server-side)
var result = server.AddUserVirtualCurrency({
PlayFabId: currentPlayerId,
VirtualCurrency: "HC",
Amount: amount
});
if (result && result.Balance !== undefined) {
log.info("Currency granted: " + amount + " to " + currentPlayerId + " reason: " + args.reason);
return { success: true, newBalance: result.Balance };
}
return { error: "Failed to grant currency" };
};
handlers.ServerValidatePurchase = function (args, context) {
// Server-side purchase validation.
// Validates item exists in catalog, checks balance, deducts currency, grants item.
// Price comes from server-side catalog, not client.
if (!args || !args.itemId) {
return { error: "Missing required parameter (itemId)" };
}
var itemId = args.itemId;
var catalogVersion = args.catalogVersion || "Catalog";
// Check inventory (server-side)
var inventory = server.GetUserInventory({ PlayFabId: currentPlayerId });
if (!inventory || !inventory.Inventory) {
return { error: "Failed to retrieve inventory" };
}
var alreadyOwns = inventory.Inventory.some(function (item) {
return item.ItemId === itemId;
});
if (alreadyOwns) {
return { error: "Player already owns this item" };
}
// SECURITY NOTE: In production, price should be looked up from the catalog
// using server-side catalog API. This is a placeholder that requires
// catalog configuration verification.
// For now, return an error indicating server-side price lookup is needed.
return { error: "Server-side price lookup not yet configured. Configure catalog first." };
};