From 34107bdd34749d71de897aa9d76ad6e6c4339d07 Mon Sep 17 00:00:00 2001 From: Marten Klitzke Date: Wed, 7 Oct 2026 16:18:04 +0200 Subject: [PATCH 1/4] feat: read buy-back detail pages and upgrade buy-back prices Two actions for the FleetYards buy-back sync: syncBuybackDetail fetches /pledge/buyback/ for a numeric id, and syncBuybackUpgradePrices asks RSI's upgrade API for up to four from/to prices plus their currency in one batch, the most operations RSI accepts in one request. --- __tests__/message-handler.test.ts | 142 ++++++++++++++++++++++++++++++ lib/message-handler.ts | 113 ++++++++++++++++++++++++ lib/rsi.ts | 37 ++++++++ 3 files changed, 292 insertions(+) diff --git a/__tests__/message-handler.test.ts b/__tests__/message-handler.test.ts index 862a6ae..2b141d4 100644 --- a/__tests__/message-handler.test.ts +++ b/__tests__/message-handler.test.ts @@ -28,6 +28,8 @@ describe("onMessage", () => { "identify", "sync", "syncBuyback", + "syncBuybackDetail", + "syncBuybackUpgradePrices", "verify-write", "verify-remove", ], @@ -134,6 +136,146 @@ describe("onMessage", () => { ); }); + it("fetches a buy-back detail page by pledge id", async () => { + const sendResponse = vi.fn(); + const getToken = vi.fn().mockResolvedValue("test-token"); + + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + status: 200, + text: vi.fn().mockResolvedValue("detail"), + } as any); + + await onMessage( + JSON.stringify({ action: "syncBuybackDetail", id: "1000001" }), + sendResponse, + getToken, + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result).toEqual({ + code: 200, + action: "syncBuybackDetail", + id: "1000001", + payload: "detail", + }); + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://robertsspaceindustries.com/pledge/buyback/1000001", + expect.objectContaining({ method: "GET" }) + ); + }); + + it.each([["../../account/settings"], ["1000001?x=1"], [""], [undefined]])( + "refuses a buy-back detail id of %s", + async (id) => { + const sendResponse = vi.fn(); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await onMessage( + JSON.stringify({ action: "syncBuybackDetail", id }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result.code).toBe(400); + expect(fetchSpy).not.toHaveBeenCalled(); + } + ); + + it("reads upgrade prices with their currency", async () => { + const sendResponse = vi.fn(); + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify([ + { data: { app: { pricing: { currencyCode: "EUR" } } } }, + { data: { price: { amount: 2618 } } }, + { errors: [{ message: "Ship not found " }], data: null }, + ]), + { status: 200 } + ) + ); + + await onMessage( + JSON.stringify({ + action: "syncBuybackUpgradePrices", + upgrades: [ + { from: 308, to: 19461 }, + { from: 1, to: 999999 }, + ], + }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result).toEqual({ + code: 200, + action: "syncBuybackUpgradePrices", + payload: { + currency: "EUR", + prices: [ + { from: 308, to: 19461, amount: 2618 }, + { from: 1, to: 999999, amount: null }, + ], + }, + }); + + const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(url).toBe( + "https://robertsspaceindustries.com/pledge-store/api/upgrade/v2/graphql" + ); + expect(JSON.parse(init!.body as string)).toHaveLength(3); + }); + + it("fails upgrade prices RSI answers without a currency", async () => { + const sendResponse = vi.fn(); + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ errors: [{ message: "nope" }] }), { + status: 200, + }) + ); + + await onMessage( + JSON.stringify({ + action: "syncBuybackUpgradePrices", + upgrades: [{ from: 308, to: 19461 }], + }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result.code).toBe(502); + }); + + it.each([ + [[]], + [[{ from: 1, to: 2 }, { from: 1, to: 2 }, { from: 1, to: 2 }, { from: 1, to: 2 }, { from: 1, to: 2 }]], + [[{ from: "1", to: 2 }]], + [[{ from: 1, to: -2 }]], + ["308"], + ])("refuses upgrades %j", async (upgrades) => { + const sendResponse = vi.fn(); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await onMessage( + JSON.stringify({ action: "syncBuybackUpgradePrices", upgrades }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result.code).toBe(400); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("responds to syncBuyback action without token", async () => { const sendResponse = vi.fn(); const getToken = vi.fn().mockResolvedValue(null); diff --git a/lib/message-handler.ts b/lib/message-handler.ts index 33ae4f3..d0215cb 100644 --- a/lib/message-handler.ts +++ b/lib/message-handler.ts @@ -2,6 +2,10 @@ import { identify, fetchPledges, fetchBuybacks, + fetchBuybackDetail, + fetchUpgradePrices, + MAX_UPGRADE_PRICES, + type UpgradePair, fetchCitizenPage, updateBio, } from "./rsi"; @@ -21,6 +25,8 @@ export const SUPPORTED_ACTIONS = [ "identify", "sync", "syncBuyback", + "syncBuybackDetail", + "syncBuybackUpgradePrices", "verify-write", "verify-remove", ] as const; @@ -93,6 +99,68 @@ async function verifyBio( return { code: 200, action, payload: { handle, changed } }; } +// The page asking can be any script on a FleetYards origin, so it only ever +// names a pledge by its numeric id and never a URL of its own. +const PLEDGE_ID_PATTERN = /^\d{1,12}$/; + +function isPositiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +function upgradePairs(value: unknown): UpgradePair[] | null { + if ( + !Array.isArray(value) || + value.length === 0 || + value.length > MAX_UPGRADE_PRICES + ) { + return null; + } + + const pairs = value.map((pair) => + isPositiveInteger(pair?.from) && isPositiveInteger(pair?.to) + ? { from: pair.from, to: pair.to } + : null + ); + + return pairs.every((pair) => pair) ? (pairs as UpgradePair[]) : null; +} + +type GraphqlResult = { + data?: { + app?: { pricing?: { currencyCode?: string } }; + price?: { amount?: number }; + } | null; +}; + +// One answer per operation, in order: the currency first, then one price per +// pair. A pair RSI does not know fails on its own and comes back as `null`. +async function upgradePrices(token: string, pairs: UpgradePair[]) { + const response = await fetchUpgradePrices(token, pairs); + if (!response.ok) { + return { code: response.status, error: "Upgrade prices failed" }; + } + + const results: unknown = await response.json().catch(() => undefined); + const currency = Array.isArray(results) + ? (results as GraphqlResult[])[0]?.data?.app?.pricing?.currencyCode + : undefined; + if (!currency) { + return { code: 502, error: "Upgrade prices unreadable" }; + } + + return { + code: 200, + payload: { + currency, + prices: pairs.map((pair, index) => ({ + ...pair, + amount: + (results as GraphqlResult[])[index + 1]?.data?.price?.amount ?? null, + })), + }, + }; +} + export async function onMessage( rawMessage: string, sendResponse: SendResponse, @@ -159,6 +227,51 @@ export async function onMessage( }) ); } + } else if (message?.action == "syncBuybackDetail") { + const id = String(message.id ?? ""); + const token = await getToken(); + + if (!PLEDGE_ID_PATTERN.test(id)) { + sendResponse( + JSON.stringify({ code: 400, action: message.action, error: "Invalid pledge id" }) + ); + } else if (!token) { + sendResponse( + JSON.stringify({ code: 401, action: message.action, id, error: "No RSI session" }) + ); + } else { + const response = await fetchBuybackDetail(token, id); + + sendResponse( + JSON.stringify({ + code: response.status, + action: message.action, + id, + payload: await response.text(), + }) + ); + } + } else if (message?.action == "syncBuybackUpgradePrices") { + const pairs = upgradePairs(message.upgrades); + const token = await getToken(); + + if (!pairs) { + sendResponse( + JSON.stringify({ code: 400, action: message.action, error: "Invalid upgrades" }) + ); + } else if (!token) { + sendResponse( + JSON.stringify({ code: 401, action: message.action, error: "No RSI session" }) + ); + } else { + const result = await upgradePrices(token, pairs).catch((error) => { + console.error("FY Sync: Upgrade prices failed", error); + + return { code: 500, error: "Upgrade prices failed" }; + }); + + sendResponse(JSON.stringify({ action: message.action, ...result })); + } } else if (message?.action == "sync" || message?.action == "syncBuyback") { const token = await getToken(); if (!token) { diff --git a/lib/rsi.ts b/lib/rsi.ts index be65882..e5ae440 100644 --- a/lib/rsi.ts +++ b/lib/rsi.ts @@ -54,6 +54,43 @@ export function fetchBuybacks(token: string, page = 1) { return fetchAccountPage("buy-back-pledges", token, page); } +export function fetchBuybackDetail(token: string, id: string) { + return fetch(`${RSI_BASE_URL}/pledge/buyback/${id}`, { + method: "GET", + headers: { + ...HTML_PAGE_HEADERS, + "X-Rsi-Token": token, + }, + credentials: "include", + }); +} + +export type UpgradePair = { from: number; to: number }; + +// RSI's upgrade API refuses a batch of more than five operations, and one of +// them asks for the currency the prices are in. +export const MAX_UPGRADE_PRICES = 4; + +const UPGRADE_PRICE_QUERY = + "query getPrice($from: Int!, $to: Int!) { price(from: $from, to: $to) { amount } }"; + +const PRICING_QUERY = "query pricing { app { pricing { currencyCode } } }"; + +export function fetchUpgradePrices(token: string, pairs: UpgradePair[]) { + return fetchRSIApi({ + url: `${RSI_BASE_URL}/pledge-store/api/upgrade/v2/graphql`, + payload: [ + { operationName: "pricing", variables: {}, query: PRICING_QUERY }, + ...pairs.map((pair) => ({ + operationName: "getPrice", + variables: pair, + query: UPGRADE_PRICE_QUERY, + })), + ], + rsiToken: token, + }); +} + export function fetchCitizenPage(handle: string) { return fetch(`${RSI_BASE_URL}/en/citizens/${encodeURIComponent(handle)}`, { method: "GET", From 329f35cecab29ca1d7fb67b9790773dc32c5774f Mon Sep 17 00:00:00 2001 From: Marten Klitzke Date: Wed, 7 Oct 2026 16:54:53 +0200 Subject: [PATCH 2/4] fix: answer a failed buy-back detail request and refuse incomplete price batches --- __tests__/message-handler.test.ts | 51 +++++++++++++++++++++++++++++++ lib/message-handler.ts | 27 +++++++++------- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/__tests__/message-handler.test.ts b/__tests__/message-handler.test.ts index 2b141d4..1d67303 100644 --- a/__tests__/message-handler.test.ts +++ b/__tests__/message-handler.test.ts @@ -254,6 +254,57 @@ describe("onMessage", () => { expect(result.code).toBe(502); }); + it("answers a buy-back detail request that fails", async () => { + const sendResponse = vi.fn(); + vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("offline")); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await onMessage( + JSON.stringify({ action: "syncBuybackDetail", id: "1000001" }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result).toEqual({ + code: 500, + action: "syncBuybackDetail", + id: "1000001", + error: "Buy-back detail failed", + }); + }); + + it("fails an upgrade price batch with fewer answers than pairs", async () => { + const sendResponse = vi.fn(); + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify([ + { data: { app: { pricing: { currencyCode: "EUR" } } } }, + { data: { price: { amount: 2618 } } }, + ]), + { status: 200 } + ) + ); + + await onMessage( + JSON.stringify({ + action: "syncBuybackUpgradePrices", + upgrades: [ + { from: 308, to: 19461 }, + { from: 47, to: 19337 }, + ], + }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result.code).toBe(502); + }); + it.each([ [[]], [[{ from: 1, to: 2 }, { from: 1, to: 2 }, { from: 1, to: 2 }, { from: 1, to: 2 }, { from: 1, to: 2 }]], diff --git a/lib/message-handler.ts b/lib/message-handler.ts index d0215cb..a657c7b 100644 --- a/lib/message-handler.ts +++ b/lib/message-handler.ts @@ -140,10 +140,13 @@ async function upgradePrices(token: string, pairs: UpgradePair[]) { return { code: response.status, error: "Upgrade prices failed" }; } + // A pair RSI does not price still answers, with `data: null`; a batch with + // fewer answers than questions is not read at all. const results: unknown = await response.json().catch(() => undefined); - const currency = Array.isArray(results) - ? (results as GraphqlResult[])[0]?.data?.app?.pricing?.currencyCode - : undefined; + const currency = + Array.isArray(results) && results.length === pairs.length + 1 + ? (results as GraphqlResult[])[0]?.data?.app?.pricing?.currencyCode + : undefined; if (!currency) { return { code: 502, error: "Upgrade prices unreadable" }; } @@ -240,16 +243,18 @@ export async function onMessage( JSON.stringify({ code: 401, action: message.action, id, error: "No RSI session" }) ); } else { - const response = await fetchBuybackDetail(token, id); - - sendResponse( - JSON.stringify({ + const result = await fetchBuybackDetail(token, id) + .then(async (response) => ({ code: response.status, - action: message.action, - id, payload: await response.text(), - }) - ); + })) + .catch((error) => { + console.error("FY Sync: Buy-back detail failed", error); + + return { code: 500, error: "Buy-back detail failed" }; + }); + + sendResponse(JSON.stringify({ action: message.action, id, ...result })); } } else if (message?.action == "syncBuybackUpgradePrices") { const pairs = upgradePairs(message.upgrades); From fc9f4825fa8e11bfc82e154d9511d85079cca5c8 Mon Sep 17 00:00:00 2001 From: Marten Klitzke Date: Wed, 7 Oct 2026 17:01:49 +0200 Subject: [PATCH 3/4] refactor: drop the upgrade buy-back price action --- __tests__/message-handler.test.ts | 123 ------------------------------ lib/message-handler.ts | 86 --------------------- lib/rsi.ts | 26 ------- 3 files changed, 235 deletions(-) diff --git a/__tests__/message-handler.test.ts b/__tests__/message-handler.test.ts index 1d67303..679d9e3 100644 --- a/__tests__/message-handler.test.ts +++ b/__tests__/message-handler.test.ts @@ -29,7 +29,6 @@ describe("onMessage", () => { "sync", "syncBuyback", "syncBuybackDetail", - "syncBuybackUpgradePrices", "verify-write", "verify-remove", ], @@ -184,76 +183,6 @@ describe("onMessage", () => { } ); - it("reads upgrade prices with their currency", async () => { - const sendResponse = vi.fn(); - - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify([ - { data: { app: { pricing: { currencyCode: "EUR" } } } }, - { data: { price: { amount: 2618 } } }, - { errors: [{ message: "Ship not found " }], data: null }, - ]), - { status: 200 } - ) - ); - - await onMessage( - JSON.stringify({ - action: "syncBuybackUpgradePrices", - upgrades: [ - { from: 308, to: 19461 }, - { from: 1, to: 999999 }, - ], - }), - sendResponse, - vi.fn().mockResolvedValue("test-token"), - "1.0.0" - ); - - const result = JSON.parse(sendResponse.mock.calls[0]![0]); - expect(result).toEqual({ - code: 200, - action: "syncBuybackUpgradePrices", - payload: { - currency: "EUR", - prices: [ - { from: 308, to: 19461, amount: 2618 }, - { from: 1, to: 999999, amount: null }, - ], - }, - }); - - const [url, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; - expect(url).toBe( - "https://robertsspaceindustries.com/pledge-store/api/upgrade/v2/graphql" - ); - expect(JSON.parse(init!.body as string)).toHaveLength(3); - }); - - it("fails upgrade prices RSI answers without a currency", async () => { - const sendResponse = vi.fn(); - - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response(JSON.stringify({ errors: [{ message: "nope" }] }), { - status: 200, - }) - ); - - await onMessage( - JSON.stringify({ - action: "syncBuybackUpgradePrices", - upgrades: [{ from: 308, to: 19461 }], - }), - sendResponse, - vi.fn().mockResolvedValue("test-token"), - "1.0.0" - ); - - const result = JSON.parse(sendResponse.mock.calls[0]![0]); - expect(result.code).toBe(502); - }); - it("answers a buy-back detail request that fails", async () => { const sendResponse = vi.fn(); vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("offline")); @@ -275,58 +204,6 @@ describe("onMessage", () => { }); }); - it("fails an upgrade price batch with fewer answers than pairs", async () => { - const sendResponse = vi.fn(); - - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response( - JSON.stringify([ - { data: { app: { pricing: { currencyCode: "EUR" } } } }, - { data: { price: { amount: 2618 } } }, - ]), - { status: 200 } - ) - ); - - await onMessage( - JSON.stringify({ - action: "syncBuybackUpgradePrices", - upgrades: [ - { from: 308, to: 19461 }, - { from: 47, to: 19337 }, - ], - }), - sendResponse, - vi.fn().mockResolvedValue("test-token"), - "1.0.0" - ); - - const result = JSON.parse(sendResponse.mock.calls[0]![0]); - expect(result.code).toBe(502); - }); - - it.each([ - [[]], - [[{ from: 1, to: 2 }, { from: 1, to: 2 }, { from: 1, to: 2 }, { from: 1, to: 2 }, { from: 1, to: 2 }]], - [[{ from: "1", to: 2 }]], - [[{ from: 1, to: -2 }]], - ["308"], - ])("refuses upgrades %j", async (upgrades) => { - const sendResponse = vi.fn(); - const fetchSpy = vi.spyOn(globalThis, "fetch"); - - await onMessage( - JSON.stringify({ action: "syncBuybackUpgradePrices", upgrades }), - sendResponse, - vi.fn().mockResolvedValue("test-token"), - "1.0.0" - ); - - const result = JSON.parse(sendResponse.mock.calls[0]![0]); - expect(result.code).toBe(400); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - it("responds to syncBuyback action without token", async () => { const sendResponse = vi.fn(); const getToken = vi.fn().mockResolvedValue(null); diff --git a/lib/message-handler.ts b/lib/message-handler.ts index a657c7b..0c5509f 100644 --- a/lib/message-handler.ts +++ b/lib/message-handler.ts @@ -3,9 +3,6 @@ import { fetchPledges, fetchBuybacks, fetchBuybackDetail, - fetchUpgradePrices, - MAX_UPGRADE_PRICES, - type UpgradePair, fetchCitizenPage, updateBio, } from "./rsi"; @@ -26,7 +23,6 @@ export const SUPPORTED_ACTIONS = [ "sync", "syncBuyback", "syncBuybackDetail", - "syncBuybackUpgradePrices", "verify-write", "verify-remove", ] as const; @@ -103,67 +99,6 @@ async function verifyBio( // names a pledge by its numeric id and never a URL of its own. const PLEDGE_ID_PATTERN = /^\d{1,12}$/; -function isPositiveInteger(value: unknown): value is number { - return Number.isSafeInteger(value) && (value as number) > 0; -} - -function upgradePairs(value: unknown): UpgradePair[] | null { - if ( - !Array.isArray(value) || - value.length === 0 || - value.length > MAX_UPGRADE_PRICES - ) { - return null; - } - - const pairs = value.map((pair) => - isPositiveInteger(pair?.from) && isPositiveInteger(pair?.to) - ? { from: pair.from, to: pair.to } - : null - ); - - return pairs.every((pair) => pair) ? (pairs as UpgradePair[]) : null; -} - -type GraphqlResult = { - data?: { - app?: { pricing?: { currencyCode?: string } }; - price?: { amount?: number }; - } | null; -}; - -// One answer per operation, in order: the currency first, then one price per -// pair. A pair RSI does not know fails on its own and comes back as `null`. -async function upgradePrices(token: string, pairs: UpgradePair[]) { - const response = await fetchUpgradePrices(token, pairs); - if (!response.ok) { - return { code: response.status, error: "Upgrade prices failed" }; - } - - // A pair RSI does not price still answers, with `data: null`; a batch with - // fewer answers than questions is not read at all. - const results: unknown = await response.json().catch(() => undefined); - const currency = - Array.isArray(results) && results.length === pairs.length + 1 - ? (results as GraphqlResult[])[0]?.data?.app?.pricing?.currencyCode - : undefined; - if (!currency) { - return { code: 502, error: "Upgrade prices unreadable" }; - } - - return { - code: 200, - payload: { - currency, - prices: pairs.map((pair, index) => ({ - ...pair, - amount: - (results as GraphqlResult[])[index + 1]?.data?.price?.amount ?? null, - })), - }, - }; -} - export async function onMessage( rawMessage: string, sendResponse: SendResponse, @@ -256,27 +191,6 @@ export async function onMessage( sendResponse(JSON.stringify({ action: message.action, id, ...result })); } - } else if (message?.action == "syncBuybackUpgradePrices") { - const pairs = upgradePairs(message.upgrades); - const token = await getToken(); - - if (!pairs) { - sendResponse( - JSON.stringify({ code: 400, action: message.action, error: "Invalid upgrades" }) - ); - } else if (!token) { - sendResponse( - JSON.stringify({ code: 401, action: message.action, error: "No RSI session" }) - ); - } else { - const result = await upgradePrices(token, pairs).catch((error) => { - console.error("FY Sync: Upgrade prices failed", error); - - return { code: 500, error: "Upgrade prices failed" }; - }); - - sendResponse(JSON.stringify({ action: message.action, ...result })); - } } else if (message?.action == "sync" || message?.action == "syncBuyback") { const token = await getToken(); if (!token) { diff --git a/lib/rsi.ts b/lib/rsi.ts index e5ae440..a2da258 100644 --- a/lib/rsi.ts +++ b/lib/rsi.ts @@ -65,32 +65,6 @@ export function fetchBuybackDetail(token: string, id: string) { }); } -export type UpgradePair = { from: number; to: number }; - -// RSI's upgrade API refuses a batch of more than five operations, and one of -// them asks for the currency the prices are in. -export const MAX_UPGRADE_PRICES = 4; - -const UPGRADE_PRICE_QUERY = - "query getPrice($from: Int!, $to: Int!) { price(from: $from, to: $to) { amount } }"; - -const PRICING_QUERY = "query pricing { app { pricing { currencyCode } } }"; - -export function fetchUpgradePrices(token: string, pairs: UpgradePair[]) { - return fetchRSIApi({ - url: `${RSI_BASE_URL}/pledge-store/api/upgrade/v2/graphql`, - payload: [ - { operationName: "pricing", variables: {}, query: PRICING_QUERY }, - ...pairs.map((pair) => ({ - operationName: "getPrice", - variables: pair, - query: UPGRADE_PRICE_QUERY, - })), - ], - rsiToken: token, - }); -} - export function fetchCitizenPage(handle: string) { return fetch(`${RSI_BASE_URL}/en/citizens/${encodeURIComponent(handle)}`, { method: "GET", From 78bf70a406ac1d69f2ad25d4254b310696db70eb Mon Sep 17 00:00:00 2001 From: Marten Klitzke Date: Wed, 7 Oct 2026 17:14:03 +0200 Subject: [PATCH 4/4] feat: read the account's store pricing for buy-back prices syncBuybackPricing asks for a store token the way RSI's own pages do, then reads the currency, exchange rate and tax RSI converts the account's prices with. --- __tests__/message-handler.test.ts | 98 +++++++++++++++++++++++++++++++ lib/message-handler.ts | 64 ++++++++++++++++++++ lib/rsi.ts | 26 ++++++++ 3 files changed, 188 insertions(+) diff --git a/__tests__/message-handler.test.ts b/__tests__/message-handler.test.ts index 679d9e3..6da73e3 100644 --- a/__tests__/message-handler.test.ts +++ b/__tests__/message-handler.test.ts @@ -29,6 +29,7 @@ describe("onMessage", () => { "sync", "syncBuyback", "syncBuybackDetail", + "syncBuybackPricing", "verify-write", "verify-remove", ], @@ -204,6 +205,103 @@ describe("onMessage", () => { }); }); + it("reads the account's store pricing after asking for a store token", async () => { + const sendResponse = vi.fn(); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: 1, data: "token" }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + data: { + app: { + pricing: { + currencyCode: "EUR", + exchangeRate: 8800, + taxRate: 1900, + isTaxInclusive: true, + }, + }, + }, + }, + ]), + { status: 200 } + ) + ); + + await onMessage( + JSON.stringify({ action: "syncBuybackPricing" }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result).toEqual({ + code: 200, + action: "syncBuybackPricing", + payload: { + currencyCode: "EUR", + exchangeRate: 8800, + taxRate: 1900, + isTaxInclusive: true, + }, + }); + expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([ + "https://robertsspaceindustries.com/api/account/v2/setAuthToken", + "https://robertsspaceindustries.com/pledge-store/api/upgrade/v2/graphql", + ]); + }); + + // Without a store token RSI prices in USD whatever the account uses, so + // reading on would convert other currencies with the wrong rate. + it("reads no pricing when RSI refuses the store token", async () => { + const sendResponse = vi.fn(); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue( + new Response(JSON.stringify({ success: 0 }), { status: 200 }) + ); + + await onMessage( + JSON.stringify({ action: "syncBuybackPricing" }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result.code).toBe(502); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("fails store pricing RSI answers without its rates", async () => { + const sendResponse = vi.fn(); + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + new Response(JSON.stringify({ success: 1 }), { status: 200 }) + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify([{ data: { app: { pricing: { currencyCode: "EUR" } } } }]), + { status: 200 } + ) + ); + + await onMessage( + JSON.stringify({ action: "syncBuybackPricing" }), + sendResponse, + vi.fn().mockResolvedValue("test-token"), + "1.0.0" + ); + + const result = JSON.parse(sendResponse.mock.calls[0]![0]); + expect(result.code).toBe(502); + }); + it("responds to syncBuyback action without token", async () => { const sendResponse = vi.fn(); const getToken = vi.fn().mockResolvedValue(null); diff --git a/lib/message-handler.ts b/lib/message-handler.ts index 0c5509f..12f5f4f 100644 --- a/lib/message-handler.ts +++ b/lib/message-handler.ts @@ -3,6 +3,8 @@ import { fetchPledges, fetchBuybacks, fetchBuybackDetail, + setStoreAuthToken, + fetchStorePricing, fetchCitizenPage, updateBio, } from "./rsi"; @@ -23,6 +25,7 @@ export const SUPPORTED_ACTIONS = [ "sync", "syncBuyback", "syncBuybackDetail", + "syncBuybackPricing", "verify-write", "verify-remove", ] as const; @@ -99,6 +102,51 @@ async function verifyBio( // names a pledge by its numeric id and never a URL of its own. const PLEDGE_ID_PATTERN = /^\d{1,12}$/; +type StorePricing = { + currencyCode: string; + exchangeRate: number; + taxRate: number; + isTaxInclusive: boolean; +}; + +function isStorePricing(value: any): value is StorePricing { + return ( + typeof value?.currencyCode === "string" && + Number.isFinite(value?.exchangeRate) && + Number.isFinite(value?.taxRate) && + typeof value?.isTaxInclusive === "boolean" + ); +} + +// The currency, exchange rate and tax RSI converts the account's prices with, +// so FleetYards can turn a buy-back price back into RSI's own USD figure. +async function storePricing(token: string) { + const auth = await setStoreAuthToken(token); + if (!auth.ok || !(await reportsSuccess(auth))) { + return { code: auth.ok ? 502 : auth.status, error: "Store token failed" }; + } + + const response = await fetchStorePricing(token); + if (!response.ok) { + return { code: response.status, error: "Store pricing failed" }; + } + + const results: any = await response.json().catch(() => undefined); + const pricing = Array.isArray(results) + ? results[0]?.data?.app?.pricing + : undefined; + if (!isStorePricing(pricing)) { + return { code: 502, error: "Store pricing unreadable" }; + } + + const { currencyCode, exchangeRate, taxRate, isTaxInclusive } = pricing; + + return { + code: 200, + payload: { currencyCode, exchangeRate, taxRate, isTaxInclusive }, + }; +} + export async function onMessage( rawMessage: string, sendResponse: SendResponse, @@ -191,6 +239,22 @@ export async function onMessage( sendResponse(JSON.stringify({ action: message.action, id, ...result })); } + } else if (message?.action == "syncBuybackPricing") { + const token = await getToken(); + + if (!token) { + sendResponse( + JSON.stringify({ code: 401, action: message.action, error: "No RSI session" }) + ); + } else { + const result = await storePricing(token).catch((error) => { + console.error("FY Sync: Store pricing failed", error); + + return { code: 500, error: "Store pricing failed" }; + }); + + sendResponse(JSON.stringify({ action: message.action, ...result })); + } } else if (message?.action == "sync" || message?.action == "syncBuyback") { const token = await getToken(); if (!token) { diff --git a/lib/rsi.ts b/lib/rsi.ts index a2da258..e3e0aae 100644 --- a/lib/rsi.ts +++ b/lib/rsi.ts @@ -65,6 +65,32 @@ export function fetchBuybackDetail(token: string, id: string) { }); } +// The store prices in the account's currency once it has a store token, which +// RSI's own pages ask for the same way before they show a price. Without one +// it answers in USD whatever the account uses. +export function setStoreAuthToken(token: string) { + return fetchRSIApi({ + url: `${RSI_BASE_URL}/api/account/v2/setAuthToken`, + payload: {}, + rsiToken: token, + }); +} + +export function fetchStorePricing(token: string) { + return fetchRSIApi({ + url: `${RSI_BASE_URL}/pledge-store/api/upgrade/v2/graphql`, + payload: [ + { + operationName: "pricing", + variables: {}, + query: + "query pricing { app { pricing { currencyCode exchangeRate taxRate isTaxInclusive } } }", + }, + ], + rsiToken: token, + }); +} + export function fetchCitizenPage(handle: string) { return fetch(`${RSI_BASE_URL}/en/citizens/${encodeURIComponent(handle)}`, { method: "GET",