text Untitled 6 views
// 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." };
};