From 0fdc6abdc191214d0445c9c97c7ded71ca777145 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 21 Jan 2026 22:31:00 +0100 Subject: [PATCH] Clarify PlayFab test endpoint inputs --- README.md | 12 ++++- src/routes/inventory.routes.js | 77 ++++++++++++++++++++++++++++++++- src/services/playfab.service.js | 19 +++++--- tests/playfab.test.js | 18 ++++++++ 4 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 tests/playfab.test.js diff --git a/README.md b/README.md index a9a336c..0a9e250 100644 --- a/README.md +++ b/README.md @@ -190,12 +190,19 @@ curl "http://localhost:3000/inventory/minecraft?includeReceipt=false" -H "Auth curl -X POST http://localhost:3000/inventory/playfab -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"sessionTicket":"","count":50}' ``` -### 9) Captures (screenshots) → `/captures/screenshots` +### 9) PlayFab inventory test (title id e9d1) → `/inventory/playfab/test` +```bash +curl -X POST http://localhost:3000/inventory/playfab/test -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"playfabToken":"XBL3.0 x=;","entityType":"title_player_account","count":50}' +``` +* `playfabToken` comes from `POST /auth/callback` in this API (response field `playfabToken`). +* Use `entityType=master_player_account` to target the master entity. If you want a specific entity id, pass `entityId`. Otherwise the service uses the PlayFabId returned by LoginWithXbox. + +### 10) Captures (screenshots) → `/captures/screenshots` ```bash curl "http://localhost:3000/captures/screenshots?max=24" -H "Authorization: Bearer " -H "x-xbl-token: XBL3.0 x=;" ``` -### 10) Debug: decode token → `/debug/decode-token` (non-production) +### 11) Debug: decode token → `/debug/decode-token` (non-production) ```bash curl -X POST http://localhost:3000/debug/decode-token -H "Authorization: Bearer " -H "Content-Type: application/json" -d '{"token":"XBL3.0 x=;","type":"xsts"}' ``` @@ -255,6 +262,7 @@ curl -X POST http://localhost:3000/debug/decode-token -H "Authorization: Beare | Method | Endpoint | Description | Headers | |-------:|------------------------------------|-------------------------------------------------------|--------------| | POST | `/inventory/playfab` | PlayFab inventory via SessionTicket/EntityToken | — | +| POST | `/inventory/playfab/test` | PlayFab inventory test via XSTS (title id e9d1) | — | | GET | `/inventory/minecraft` | Minecraft entitlements (optional `includeReceipt`) | `x-mc-token` | | GET | `/inventory/minecraft/balances` | Minecraft Marketplace currency balances | `x-mc-token` | | GET | `/inventory/minecraft/creators/top`| Top creators from entitlements (by item count) | `x-mc-token` | diff --git a/src/routes/inventory.routes.js b/src/routes/inventory.routes.js index 064d65b..3b957ae 100644 --- a/src/routes/inventory.routes.js +++ b/src/routes/inventory.routes.js @@ -3,11 +3,12 @@ import Joi from "joi"; import jwtLib from "jsonwebtoken"; import {jwtMiddleware} from "../utils/jwt.js"; import {asyncHandler} from "../utils/async.js"; -import {getEntityToken, getPlayFabInventory} from "../services/playfab.service.js"; +import {getEntityToken, getPlayFabInventory, loginWithXbox} from "../services/playfab.service.js"; import {getMCBalances, getMCInventory} from "../services/minecraft.service.js"; import {badRequest} from "../utils/httpError.js"; const router = express.Router(); +const PLAYFAB_TEST_TITLE_ID = "e9d1"; /** * @swagger @@ -66,6 +67,80 @@ router.post("/playfab", jwtMiddleware, asyncHandler(async (req, res) => { res.json({entity: entityData.Entity, items: inv.Items || []}); })); +/** + * @swagger + * /inventory/playfab/test: + * post: + * summary: Test PlayFab inventory for title id e9d1 + * description: > + * Logs in with a PlayFab XSTS token, exchanges for an EntityToken, and returns inventory + * items for the selected entity type using title id e9d1. + * tags: [Inventory] + * security: + * - BearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [playfabToken] + * properties: + * playfabToken: + * type: string + * description: PlayFab XSTS token in the form XBL3.0 x={uhs};{token} + * example: "XBL3.0 x=;" + * entityType: + * type: string + * enum: [title_player_account, master_player_account] + * default: title_player_account + * example: "title_player_account" + * entityId: + * type: string + * description: Optional entity id override for the chosen entity type + * collectionId: + * type: string + * default: "default" + * example: "default" + * count: + * type: integer + * default: 50 + * minimum: 1 + * maximum: 200 + * responses: + * 200: + * description: PlayFab inventory items for the chosen entity + */ +router.post("/playfab/test", jwtMiddleware, asyncHandler(async (req, res) => { + const schema = Joi.object({ + playfabToken: Joi.string().required(), + entityType: Joi.string().valid("title_player_account", "master_player_account").default("title_player_account"), + entityId: Joi.string().optional(), + collectionId: Joi.string().default("default"), + count: Joi.number().integer().min(1).max(200).default(50) + }); + const {value, error} = schema.validate(req.body); + if (error) throw badRequest(error.message); + + const loginData = await loginWithXbox(value.playfabToken, PLAYFAB_TEST_TITLE_ID); + const sessionTicket = loginData.SessionTicket; + const playFabId = loginData.PlayFabId; + if (value.entityType === "master_player_account" && !value.entityId && !playFabId) { + throw badRequest("PlayFabId is required for master_player_account"); + } + + const entityOverride = value.entityId ? { + Type: value.entityType, Id: value.entityId + } : value.entityType === "master_player_account" ? { + Type: value.entityType, Id: playFabId + } : null; + + const entityData = await getEntityToken(sessionTicket, entityOverride || undefined, PLAYFAB_TEST_TITLE_ID); + const inv = await getPlayFabInventory(entityData.EntityToken, entityData.Entity.Id, entityData.Entity.Type, value.collectionId, value.count, PLAYFAB_TEST_TITLE_ID); + + res.json({entity: entityData.Entity, items: inv.Items || []}); +})); + /** * @swagger * /inventory/minecraft: diff --git a/src/services/playfab.service.js b/src/services/playfab.service.js index 1eca10b..90d68eb 100644 --- a/src/services/playfab.service.js +++ b/src/services/playfab.service.js @@ -4,9 +4,14 @@ import {createHttp} from "../utils/http.js"; const http = createHttp(env.HTTP_TIMEOUT_MS); -export async function loginWithXbox(xstsToken, titleId = env.PLAYFAB_TITLE_ID) { +export function resolvePlayFabTitleId(titleId = env.PLAYFAB_TITLE_ID) { if (!titleId) throw badRequest("PLAYFAB_TITLE_ID missing. Set it in .env"); - const baseUrl = `https://${titleId}.playfabapi.com/Client/LoginWithXbox`; + return titleId; +} + +export async function loginWithXbox(xstsToken, titleId = env.PLAYFAB_TITLE_ID) { + const resolvedTitleId = resolvePlayFabTitleId(titleId); + const baseUrl = `https://${resolvedTitleId}.playfabapi.com/Client/LoginWithXbox`; try { const {data} = await http.post(baseUrl, { TitleId: titleId, @@ -31,9 +36,10 @@ export async function loginWithXbox(xstsToken, titleId = env.PLAYFAB_TITLE_ID) { } } -export async function getEntityToken(sessionTicket, entity) { +export async function getEntityToken(sessionTicket, entity, titleId = env.PLAYFAB_TITLE_ID) { if (!sessionTicket) throw badRequest("sessionTicket is required"); - const url = `https://${env.PLAYFAB_TITLE_ID}.playfabapi.com/Authentication/GetEntityToken`; + const resolvedTitleId = resolvePlayFabTitleId(titleId); + const url = `https://${resolvedTitleId}.playfabapi.com/Authentication/GetEntityToken`; try { const {data} = await http.post(url, entity ? {Entity: entity} : {}, { headers: { @@ -57,9 +63,10 @@ export async function getEntityToken(sessionTicket, entity) { } } -export async function getPlayFabInventory(entityToken, entityId, entityType = "title_player_account", collectionId = "default", count = 50) { +export async function getPlayFabInventory(entityToken, entityId, entityType = "title_player_account", collectionId = "default", count = 50, titleId = env.PLAYFAB_TITLE_ID) { if (!entityToken || !entityId) throw badRequest("entityToken and entityId are required"); - const url = `https://${env.PLAYFAB_TITLE_ID}.playfabapi.com/Inventory/GetInventoryItems`; + const resolvedTitleId = resolvePlayFabTitleId(titleId); + const url = `https://${resolvedTitleId}.playfabapi.com/Inventory/GetInventoryItems`; try { const {data} = await http.post(url, { Entity: {Type: entityType, Id: entityId}, CollectionId: collectionId, Count: count diff --git a/tests/playfab.test.js b/tests/playfab.test.js new file mode 100644 index 0000000..f033d50 --- /dev/null +++ b/tests/playfab.test.js @@ -0,0 +1,18 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +process.env.NODE_ENV = "test"; +process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret-1234567890"; +process.env.CLIENT_ID = process.env.CLIENT_ID || "test-client"; + +const {resolvePlayFabTitleId} = await import("../src/services/playfab.service.js"); + +test("resolvePlayFabTitleId uses explicit title id", () => { + assert.equal(resolvePlayFabTitleId("e9d1"), "e9d1"); +}); + +test("resolvePlayFabTitleId rejects empty title id", () => { + assert.throws(() => resolvePlayFabTitleId(""), { + message: "PLAYFAB_TITLE_ID missing. Set it in .env" + }); +});