From 4d1ca4f17387877637e7ac06fc3b2a855f54788c Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 17 Jan 2026 08:53:16 +0100 Subject: [PATCH] Add PlayFab inventory filter shortcuts --- README.md | 13 ++++++++ src/routes/inventory.routes.js | 35 ++++++++++++++++++++ src/services/playfab.service.js | 24 ++++++++++++++ src/utils/swagger.js | 58 +++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+) diff --git a/README.md b/README.md index 12104d3..7095a7e 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ Optional Marketplace headers (when enabled): | ------ | -------------------------------------------- | -------------------------------- | ---------------------------------------------- | | GET | `/inventory/balances` | Player virtual currency balances | | | GET | `/inventory/entitlements?includeReceipt=true | false` | Player entitlements (optionally with receipts) | +| GET | `/inventory/playfab/items?filter=` | PlayFab inventory items with official filters | #### Purchase @@ -269,6 +270,10 @@ Headers: `authorization` (JWT), `x-mc-token` (required). Returns Minecoin and ot Headers: `authorization` (JWT), `x-mc-token` (required). Returns `{ count, entitlements: [] }` when called via `/inventory`; purchase routes return the raw upstream payload. +#### `GET /inventory/playfab/items?filter=` + +Headers: `authorization` (JWT), `x-playfab-session` (required), `x-playfab-id` (required). Returns the PlayFab Economy inventory payload with official PlayFab filter queries. Supported filter fields are `type`, `id`, and `stackId` with `eq`. + #### `POST /purchase/quote` Headers: `authorization` (JWT) and **either** `x-mc-token` **or** `x-playfab-session`. @@ -335,6 +340,14 @@ curl -sS "$BASE/inventory/balances" \ curl -sS "$BASE/inventory/entitlements?includeReceipt=true" \ -H "Authorization: Bearer $TOKEN" -H "x-mc-token: $MC" +# PlayFab inventory items with filter query +curl -sS "$BASE/inventory/playfab/items?filter=type%20eq%20%27Subscription%27" \ + -H "Authorization: Bearer $TOKEN" -H "x-playfab-session: $ST" -H "x-playfab-id: " + +# PlayFab inventory items with shortcut parameters +curl -sS "$BASE/inventory/playfab/items?type=Subscription" \ + -H "Authorization: Bearer $TOKEN" -H "x-playfab-session: $ST" -H "x-playfab-id: " + # Debug: decode multiple tokens curl -sS -X POST "$BASE/debug/decode-token" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ diff --git a/src/routes/inventory.routes.js b/src/routes/inventory.routes.js index 4936f2e..2d44682 100644 --- a/src/routes/inventory.routes.js +++ b/src/routes/inventory.routes.js @@ -3,6 +3,7 @@ import {jwtMiddleware} from "../utils/jwt.js"; import {asyncHandler} from "../utils/async.js"; import {badRequest} from "../utils/httpError.js"; import {getBalances, getInventory} from "../services/minecraft.service.js"; +import {getEntityTokenForPlayer, getInventoryItems} from "../services/playfab.service.js"; const router = express.Router(); @@ -21,4 +22,38 @@ router.get("/entitlements", jwtMiddleware, asyncHandler(async (req, res) => { res.json({count: data.length, entitlements: data}); })); +router.get("/playfab/items", jwtMiddleware, asyncHandler(async (req, res) => { + const sessionTicket = req.headers["x-playfab-session"]; + const playfabId = req.headers["x-playfab-id"]; + if (!sessionTicket || !playfabId) throw badRequest("x-playfab-session and x-playfab-id are required"); + const filter = String(req.query.filter || "").trim(); + const type = req.query.type ? String(req.query.type).trim() : ""; + const id = req.query.id ? String(req.query.id).trim() : ""; + const stackId = req.query.stackId ? String(req.query.stackId).trim() : ""; + if (filter && (type || id || stackId)) throw badRequest("Use filter or type/id/stackId, not both"); + if (!filter && !type && !id && !stackId) throw badRequest("Provide filter or type/id/stackId"); + const countRaw = req.query.count; + const count = countRaw === undefined ? null : Number(countRaw); + if (count !== null && (!Number.isInteger(count) || count < 1 || count > 200)) { + throw badRequest("count must be an integer between 1 and 200"); + } + const continuationToken = req.query.continuationToken ? String(req.query.continuationToken) : null; + const entityToken = await getEntityTokenForPlayer(sessionTicket, playfabId); + const resolvedFilter = filter || buildInventoryFilter({type, id, stackId}); + const data = await getInventoryItems(entityToken, {filter: resolvedFilter, count, continuationToken}); + res.json(data); +})); + +function buildInventoryFilter({type, id, stackId}) { + const parts = []; + if (type) parts.push(`type eq '${escapeFilterValue(type)}'`); + if (id) parts.push(`id eq '${escapeFilterValue(id)}'`); + if (stackId) parts.push(`stackId eq '${escapeFilterValue(stackId)}'`); + return parts.join(" and "); +} + +function escapeFilterValue(value) { + return value.replaceAll("'", "''"); +} + export default router; diff --git a/src/services/playfab.service.js b/src/services/playfab.service.js index 7e62cea..fdc91d1 100644 --- a/src/services/playfab.service.js +++ b/src/services/playfab.service.js @@ -35,3 +35,27 @@ export async function redeemOnestore(entityToken, redeemToken, xuid) { throw internal("Failed to redeem", err.response?.data || err.message); } } + +export async function getEntityTokenForPlayer(sessionTicket, playfabId) { + const token = await getEntityToken(sessionTicket, {Id: playfabId, Type: "master_player_account"}); + if (!token) throw internal("Failed to resolve EntityToken"); + return token; +} + +export async function getInventoryItems(entityToken, {filter, count, continuationToken} = {}) { + const url = `https://${env.PLAYFAB_TITLE_ID}.playfabapi.com/Inventory/GetInventoryItems`; + try { + const body = {}; + if (filter) body.Filter = filter; + if (count !== null && count !== undefined) body.Count = count; + if (continuationToken) body.ContinuationToken = continuationToken; + const {data} = await http.post(url, body, { + headers: { + "Content-Type": "application/json", "X-EntityToken": entityToken, Accept: "application/json" + } + }); + return data?.data || {}; + } catch (err) { + throw internal("Failed to get PlayFab inventory items", err.response?.data || err.message); + } +} diff --git a/src/utils/swagger.js b/src/utils/swagger.js index d562e5e..9909518 100644 --- a/src/utils/swagger.js +++ b/src/utils/swagger.js @@ -165,6 +165,64 @@ const options = { 200: {description: "List of entitlements owned by the player."} } } + }, "/inventory/playfab/items": { + get: { + tags: ["Inventory"], + summary: "Get PlayFab inventory items", + description: "Returns PlayFab Economy inventory items using official PlayFab filter queries.", + parameters: [{ + in: "header", + name: "x-playfab-session", + required: true, + schema: {type: "string"}, + description: "PlayFab SessionTicket used to mint an EntityToken." + }, { + in: "header", + name: "x-playfab-id", + required: true, + schema: {type: "string"}, + description: "PlayFab master_player_account id for the user." + }, { + in: "query", + name: "filter", + required: false, + schema: {type: "string"}, + description: "PlayFab Economy filter query string. Supported fields: type, id, stackId with eq." + }, { + in: "query", + name: "type", + required: false, + schema: {type: "string"}, + description: "Shortcut for type eq '' when filter is omitted." + }, { + in: "query", + name: "id", + required: false, + schema: {type: "string"}, + description: "Shortcut for id eq '' when filter is omitted." + }, { + in: "query", + name: "stackId", + required: false, + schema: {type: "string"}, + description: "Shortcut for stackId eq '' when filter is omitted." + }, { + in: "query", + name: "count", + required: false, + schema: {type: "integer", minimum: 1, maximum: 200}, + description: "Optional page size (1-200)." + }, { + in: "query", + name: "continuationToken", + required: false, + schema: {type: "string"}, + description: "Continuation token from a previous response." + }], + responses: { + 200: {description: "Inventory items payload from PlayFab."} + } + } }, "/purchase/quote": { post: { tags: ["Purchase"],