Steam desktop authentication support (SteamSDK, Auth branch, audience config, username seeding) (#4683)

> **Maintainer note:** part of the private OpenFront Steam release
(Milestone 2, tracked in Linear), not a public GitHub issue. Per the
contributor policy this needs an approved/assigned issue or a maintainer
label to avoid auto-close — flagging for a maintainer to handle.

Resolves: _n/a — private Steam-release work (see note above)_

## Description:

Adds the client-side pieces for authenticating the OpenFront **Steam
desktop build** (an Electron shell) against the existing API, mirroring
the established CrazyGames integration. The native Steamworks code lives
in the desktop shell and is exposed to this client through a
`window.openfrontDesktop.steam` bridge; this PR is the renderer half.

- **`SteamSDK.ts`** (new) — thin wrapper over the desktop shell's Steam
bridge (`isOnSteam` / `getTicket` / `getUser`), mirroring
`CrazyGamesSDK`; narrows the loosely-typed `window.openfrontDesktop`
locally rather than re-declaring it.
- **`Auth.ts`** — a Steam branch at the top of `doRefreshJwt()` plus
`doSteamLogin()`, exchanging a Steam Web-API ticket at `POST
/auth/steam` for a session JWT, exactly paralleling `doCrazyGamesLogin`.
Re-exchanges on expiry (the refresh cookie is cross-site-blocked from
the `app://` origin); no ticket / not on Steam falls through to the
existing flow.
- **`Api.ts`** — `getAudience()` now sources from `BOOTSTRAP_CONFIG`
(`ClientEnv.jwtAudience()`) instead of `window.location`, so the desktop
app (running at `app://openfront`) resolves the real API base.
Behavior-preserving on web, where the injected audience already equals
the hostname-derived one.
- **`UsernameInput.ts`** — seeds the in-game display name from the Steam
persona on first launch and persists it (unlike CrazyGames). Writes only
the local display name; never the account username.
- **`SteamLinkSignpost.ts`** (new) — a one-time, dismissible notice
shown on first Steam launch that account linking is coming.

## Please complete the following:

- [ ] I have added screenshots for all UI updates — ⚠️ _the new
first-launch link signpost is a UI addition; it renders only inside the
Steam desktop shell. Screenshots to be added by the maintainer._
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file — signpost strings go through
`translateText()` with keys added to `resources/lang/en.json`
(`steam.link_signpost`, `common.got_it`).
- [x] I have added relevant tests to the test directory — unit tests for
`SteamSDK` (incl. bridge-throws degradation), the `Auth.ts` Steam
exchange + fall-through, the `Api.ts` audience change, username seeding,
and the signpost gating. Full suite green (1976) + `tsc --noEmit` clean.

## Please put your Discord username so you can be contacted if a bug or
regression is found:

_⚠️ maintainer to fill_

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01BWxUzYb2uqjcBFQuNSJhMy
This commit is contained in:
Josh Harris
2026-07-23 14:13:14 +01:00
committed by GitHub
parent ddc1abe146
commit 2ecdbdfe8d
15 changed files with 470 additions and 6 deletions
+1
View File
@@ -207,6 +207,7 @@
<homepage-promos></homepage-promos>
<marketing-consent-toast></marketing-consent-toast>
<steam-link-signpost></steam-link-signpost>
<!-- Main container with responsive padding -->
<main-layout class="contents">
+4
View File
@@ -370,6 +370,7 @@
"duration_minute_short": "min",
"duration_second_short": "s",
"enabled": "Enabled",
"got_it": "Got it",
"map_default": "Map default",
"month_apr": "April",
"month_aug": "August",
@@ -1406,6 +1407,9 @@
"toggle_achievements": "Toggle achievements",
"water_nukes": "Water nukes"
},
"steam": {
"link_signpost": "Have an existing OpenFront account? Account linking is coming in a later update."
},
"store": {
"already_subscribed": "Already subscribed.",
"change_tier_failed": "Couldn't update your subscription. Please try again.",
+3 -3
View File
@@ -690,9 +690,9 @@ export function getApiBase() {
}
export function getAudience() {
const { hostname } = new URL(window.location.href);
const domainname = hostname.split(".").slice(-2).join(".");
return domainname;
// Sourced from BOOTSTRAP_CONFIG (server/desktop-injected) rather than
// window.location, so the desktop app (app://openfront) targets real infra.
return ClientEnv.jwtAudience();
}
// Check if the user's account is linked to a Discord, Google, or email account.
+36
View File
@@ -5,6 +5,7 @@ import { TokenPayload, TokenPayloadSchema } from "../core/ApiSchemas";
import { base64urlToUuid } from "../core/Base64";
import { getApiBase, getAudience } from "./Api";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { steamSDK } from "./SteamSDK";
import { generateCryptoRandomUUID } from "./Utils";
export type UserAuth = { jwt: string; claims: TokenPayload } | false;
@@ -190,6 +191,14 @@ async function refreshJwt(): Promise<void> {
}
async function doRefreshJwt(): Promise<void> {
if (steamSDK.isOnSteam()) {
const ticket = await steamSDK.getTicket();
if (ticket) {
// On Steam, we exchange a Steam Web-API ticket for our session. No
// ticket (Steam unavailable) falls through to the guest flow below.
return doSteamLogin(ticket);
}
}
if (crazyGamesSDK.isOnCrazyGames()) {
const token = await crazyGamesSDK.getUserToken();
if (token) {
@@ -250,6 +259,33 @@ async function doCrazyGamesLogin(token: string): Promise<void> {
}
}
// Exchange a Steam Web-API ticket for our session. Like CrazyGames, the
// refresh cookie isn't usable from app://openfront (cross-site), so we
// re-exchange a fresh ticket on expiry rather than hitting /auth/refresh.
async function doSteamLogin(ticket: string): Promise<void> {
try {
console.log("Logging in with Steam");
const response = await fetch(getApiBase() + "/auth/steam", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticket }),
});
if (response.status !== 200) {
console.error("Steam login failed", response);
__jwt = null;
return;
}
const json = await response.json();
const { jwt, expiresIn } = json;
__expiresAt = Date.now() + expiresIn * 1000;
console.log("Steam login succeeded");
__jwt = jwt;
} catch (e) {
console.error("Steam login failed", e);
__jwt = null;
}
}
// Called when the CrazyGames auth state changes mid-session (e.g. the player
// signs in): drop the cached session so userAuth() re-exchanges the new token.
// Single-flight: Main's auth listener and the account modal's sign-in handler
+6
View File
@@ -54,6 +54,7 @@ import "./NewsModal";
import "./PlayerProfileModal";
import { RewardsModal } from "./RewardsModal";
import "./SinglePlayerModal";
import "./SteamLinkSignpost";
import { StoreModal } from "./Store";
import { TokenLoginModal } from "./TokenLoginModal";
import {
@@ -937,6 +938,11 @@ class Client {
}
const auth = await userAuth();
const playerRole = auth !== false ? (auth.claims.role ?? null) : null;
// Ensure the one-shot Steam name-seed has settled before reading
// getUsername(), mirroring how getClanCheck() runs in parallel with the
// handshake. whenSeeded() always resolves (falling back to the generated
// anon name on failure/timeout), so this can only delay, never block.
await this.usernameInput?.whenSeeded();
const newLobbyHandle = joinLobby(this.eventBus, {
gameID: lobby.gameID,
cosmetics: await getPlayerCosmeticsRefs(),
+5
View File
@@ -830,6 +830,11 @@ export class SinglePlayerModal extends BaseModal {
"username-input",
) as UsernameInput;
// Wait for the one-shot Steam name-seed to settle before reading
// getUsername(), so a fast single-player start uses the Steam persona
// rather than the interim generated anon name. Always resolves.
await usernameInput?.whenSeeded();
await crazyGamesSDK.requestMidgameAd();
this.dispatchEvent(
+55
View File
@@ -0,0 +1,55 @@
import { html, LitElement, nothing } from "lit";
import { customElement } from "lit/decorators.js";
import { steamSDK } from "./SteamSDK";
import { translateText } from "./Utils";
const SEEN_KEY = "steam_link_signpost_seen";
/**
* One-time first-launch signpost for the Steam desktop build. Account
* linking (matching a Steam player to an existing web OpenFront account)
* isn't implemented yet — a fresh install on Steam always creates a new
* account. This tells the player that once, so they aren't surprised, and
* never shows again once dismissed (or once seen, since `dismiss()` is the
* only way the seen-flag gets set).
*
* Self-hides off Steam, so mounting it unconditionally (web + desktop) is
* safe.
*/
@customElement("steam-link-signpost")
export class SteamLinkSignpost extends LitElement {
createRenderRoot() {
return this;
}
shouldShow(): boolean {
return steamSDK.isOnSteam() && localStorage.getItem(SEEN_KEY) !== "1";
}
dismiss(): void {
localStorage.setItem(SEEN_KEY, "1");
this.requestUpdate();
}
render() {
if (!this.shouldShow()) return nothing;
return html`
<div
class="fixed bottom-4 left-4 right-4 z-[10000] sm:left-auto sm:right-4 sm:w-[300px] bg-surface border border-white/10 rounded-xl shadow-[var(--shadow-malibu-blue)] p-3"
role="dialog"
aria-label=${translateText("steam.link_signpost")}
>
<p class="text-xs text-white/80 mb-2">
${translateText("steam.link_signpost")}
</p>
<button
class="w-full px-2 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg border border-transparent bg-malibu-blue text-white shadow-[var(--shadow-malibu-blue-pill)] hover:bg-aquarius transition-all cursor-pointer"
@click=${() => this.dismiss()}
>
${translateText("common.got_it")}
</button>
</div>
`;
}
}
+45
View File
@@ -0,0 +1,45 @@
interface SteamBridge {
getAuthTicket(): Promise<string | null>;
getUser(): Promise<{ steamId: string; name: string } | null>;
}
// window.openfrontDesktop is declared `unknown` by DesktopShell.ts (kept loose
// there on purpose). We know the shape the Electron preload exposes, so narrow
// it locally rather than re-declaring the global (a second `declare global`
// with a different type triggers TS2717).
function steamBridge(): SteamBridge | undefined {
const desktop = window.openfrontDesktop as
| { steam?: SteamBridge }
| undefined;
return desktop?.steam;
}
// Thin renderer wrapper over the desktop shell's Steam bridge. Mirrors
// CrazyGamesSDK; the native work lives in the Electron main process.
class SteamSDK {
isOnSteam(): boolean {
return steamBridge() !== undefined;
}
async getTicket(): Promise<string | null> {
const bridge = steamBridge();
if (!bridge) return null;
try {
return await bridge.getAuthTicket();
} catch {
return null;
}
}
async getUser(): Promise<{ steamId: string; name: string } | null> {
const bridge = steamBridge();
if (!bridge) return null;
try {
return await bridge.getUser();
} catch {
return null;
}
}
}
export const steamSDK = new SteamSDK();
+54
View File
@@ -15,6 +15,7 @@ import {
import { checkClanTagOwnership } from "./ClanApi";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { showInGameConfirm } from "./InGameModal";
import { steamSDK } from "./SteamSDK";
interface LangSelectorLike {
currentLang?: string;
@@ -37,6 +38,9 @@ export class UsernameInput extends LitElement {
// Clans aren't supported on CrazyGames — hide the tag input and never submit one.
private readonly onCrazyGames = crazyGamesSDK.isOnCrazyGames();
// Steam identity is fixed for the session (no login/logout events like
// CrazyGames), so it's only used to seed the name once in connectedCallback.
private readonly onSteam = steamSDK.isOnSteam();
@property({ type: String }) validationError: string = "";
// Ownership-check feedback (i18n key) shown inline beneath the tag input. Only
@@ -51,6 +55,13 @@ export class UsernameInput extends LitElement {
private clanCheckGen = 0;
private clanCheck: Promise<string | null> = Promise.resolve(null);
// Resolves once the one-shot Steam name-seed has settled (or immediately for
// non-Steam players). The join flow awaits this before reading getUsername()
// so a fast join can't start the game under the generated anon name before
// the Steam persona lands. Always resolves — never rejects — so a failed or
// slow getUser() falls back to the generated name instead of blocking.
private steamSeedReady: Promise<void> = Promise.resolve();
// Remove static styles since we're using Tailwind
createRenderRoot() {
@@ -155,6 +166,15 @@ export class UsernameInput extends LitElement {
return this.clanCheck;
}
// Resolves once the one-shot Steam name-seed has settled (immediately for
// non-Steam players, or once nothing was left to seed). The join flow awaits
// this before reading getUsername() so a fast join reads the Steam persona
// rather than the interim generated anon name. Never blocks: the underlying
// chain always resolves, even when getUser() fails or the persona is invalid.
public whenSeeded(): Promise<void> {
return this.steamSeedReady;
}
private startClanCheck() {
const gen = ++this.clanCheckGen;
const tag = this.clanTag;
@@ -178,6 +198,10 @@ export class UsernameInput extends LitElement {
connectedCallback() {
super.connectedCallback();
// Captured before loadStoredUsername(), which — when nothing is stored —
// fills in a fresh anon username AND persists it immediately. Checking
// localStorage afterwards would therefore never see it as empty.
const noStoredUsername = this.onSteam && !localStorage.getItem(usernameKey);
this.loadStoredUsername();
// On CrazyGames the account username is applied here but never persisted
// (see loadStoredUsername / validateAndStore), so logging out — which
@@ -196,6 +220,36 @@ export class UsernameInput extends LitElement {
this.validateAndStore();
}
});
// Seed the in-game name from the Steam persona, once, only when nothing
// is stored yet. Unlike CrazyGames, Steam persists normally (see
// validateAndStore's onCrazyGames guard), and there's no logout event to
// handle since the Steam identity is fixed for the session.
if (noStoredUsername) {
// The anon name loadStoredUsername() just generated. Only overwrite it if
// the player hasn't typed their own name while getUser() was in flight,
// so a late Steam result never clobbers a name they entered.
const generated = this.baseUsername;
// Store the seeding promise so the join path can await it (see
// whenSeeded). The chain never rejects — on any failure we keep the
// generated name — so awaiting it can only delay, never block, a join.
this.steamSeedReady = steamSDK
.getUser()
.then((user) => {
if (this.baseUsername !== generated) return;
// Steam personas can contain characters our usernames disallow (e.g.
// brackets) or exceed the length limit; strip brackets, trim, and only
// accept the persona if it validates — otherwise keep the generated
// name so the player can always start a game.
const candidate = user?.name?.replace(/[[\]]/g, "").trim();
if (candidate && validateUsername(candidate).isValid) {
this.baseUsername = candidate;
this.validateAndStore();
}
})
.catch(() => {
// Swallow: keep the generated name so the player can always play.
});
}
}
protected updated(): void {
+42 -3
View File
@@ -1,9 +1,32 @@
import { beforeEach, describe, expect, it } from "vitest";
import { getApiBase } from "../src/client/Api";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getApiBase, getAudience } from "../src/client/Api";
import { ClientEnv } from "../src/client/ClientEnv";
describe("getApiBase", () => {
function setConfig(jwtAudience: string) {
(window as any).BOOTSTRAP_CONFIG = {
gameEnv: "prod",
numWorkers: 1,
turnstileSiteKey: "x",
jwtAudience,
instanceId: "desktop",
gitCommit: "test",
};
ClientEnv.reset();
}
// setConfig sets window.BOOTSTRAP_CONFIG; clear it (and the cached env) after
// every test so a stray value can't leak into a later test.
afterEach(() => {
delete (window as any).BOOTSTRAP_CONFIG;
ClientEnv.reset();
});
describe("getApiBase localhost fallback", () => {
beforeEach(() => {
localStorage.clear();
// getAudience() now reads the audience from BOOTSTRAP_CONFIG, so the
// localhost branch is reached via jwtAudience "localhost".
setConfig("localhost");
});
// API_DOMAIN is forced empty under vitest via the vite.config `define`, so this
@@ -13,3 +36,19 @@ describe("getApiBase", () => {
expect(getApiBase()).toBe("http://localhost:8787");
});
});
describe("getAudience / getApiBase from BOOTSTRAP_CONFIG", () => {
beforeEach(() => ClientEnv.reset());
it("returns the configured audience (desktop staging)", () => {
setConfig("openfront.dev");
expect(getAudience()).toBe("openfront.dev");
expect(getApiBase()).toBe("https://api.openfront.dev");
});
it("returns the configured audience (prod)", () => {
setConfig("openfront.io");
expect(getAudience()).toBe("openfront.io");
expect(getApiBase()).toBe("https://api.openfront.io");
});
});
+41
View File
@@ -0,0 +1,41 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { steamSDK } from "../src/client/SteamSDK";
beforeEach(() => {
delete (window as any).openfrontDesktop;
});
describe("SteamSDK", () => {
it("isOnSteam is false without the bridge", () => {
expect(steamSDK.isOnSteam()).toBe(false);
});
it("isOnSteam true and passes through ticket/user with the bridge", async () => {
(window as any).openfrontDesktop = {
steam: {
getAuthTicket: vi.fn().mockResolvedValue("deadbeef"),
getUser: vi.fn().mockResolvedValue({ steamId: "77", name: "Ada" }),
},
};
expect(steamSDK.isOnSteam()).toBe(true);
expect(await steamSDK.getTicket()).toBe("deadbeef");
expect(await steamSDK.getUser()).toEqual({ steamId: "77", name: "Ada" });
});
it("getTicket degrades to null when bridge rejects", async () => {
(window as any).openfrontDesktop = {
steam: {
getAuthTicket: vi.fn().mockRejectedValue(new Error("boom")),
getUser: vi.fn(),
},
};
expect(await steamSDK.getTicket()).toBeNull();
});
it("getUser degrades to null when bridge rejects", async () => {
(window as any).openfrontDesktop = {
steam: {
getAuthTicket: vi.fn(),
getUser: vi.fn().mockRejectedValue(new Error("boom")),
},
};
expect(await steamSDK.getUser()).toBeNull();
});
});
+71
View File
@@ -0,0 +1,71 @@
import { UnsecuredJWT } from "jose";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getAuthHeader, logOut } from "../../src/client/Auth";
import { ClientEnv } from "../../src/client/ClientEnv";
import { steamSDK } from "../../src/client/SteamSDK";
function setBootstrapConfig() {
(window as any).BOOTSTRAP_CONFIG = {
gameEnv: "prod",
numWorkers: 1,
turnstileSiteKey: "x",
jwtAudience: "openfront.dev",
instanceId: "d",
gitCommit: "t",
};
ClientEnv.reset();
}
beforeEach(async () => {
setBootstrapConfig();
await logOut();
vi.restoreAllMocks();
});
describe("Steam login", () => {
it("exchanges a Steam ticket for a session JWT via POST /auth/steam", async () => {
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
vi.spyOn(steamSDK, "getTicket").mockResolvedValue("ticket123");
const jwt = new UnsecuredJWT({
jti: "some-id",
sub: "AAAAAAAAAAAAAAAAAAAAAA",
iat: Math.floor(Date.now() / 1000),
iss: "https://api.openfront.dev",
aud: "openfront.dev",
exp: Math.floor(Date.now() / 1000) + 3600,
}).encode();
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ jwt, expiresIn: 900 }), {
status: 200,
}),
);
const header = await getAuthHeader();
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/auth/steam");
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
ticket: "ticket123",
});
expect(header).toBe(`Bearer ${jwt}`);
});
it("falls through to the guest/refresh flow when no Steam ticket is available", async () => {
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
vi.spyOn(steamSDK, "getTicket").mockResolvedValue(null);
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(null, { status: 401 }));
await getAuthHeader();
expect(fetchMock).toHaveBeenCalled();
for (const call of fetchMock.mock.calls) {
expect(String(call[0])).not.toContain("/auth/steam");
}
});
});
+24
View File
@@ -0,0 +1,24 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SteamLinkSignpost } from "../../src/client/SteamLinkSignpost";
import { steamSDK } from "../../src/client/SteamSDK";
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
describe("SteamLinkSignpost", () => {
it("is visible on first Steam launch, hidden after dismiss", () => {
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
const el = new SteamLinkSignpost();
expect(el.shouldShow()).toBe(true);
el.dismiss();
expect(localStorage.getItem("steam_link_signpost_seen")).toBe("1");
expect(el.shouldShow()).toBe(false);
});
it("never shows off Steam", () => {
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(false);
expect(new SteamLinkSignpost().shouldShow()).toBe(false);
});
});
+67
View File
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { steamSDK } from "../../src/client/SteamSDK";
import { UsernameInput } from "../../src/client/UsernameInput";
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
describe("UsernameInput Steam seeding", () => {
it("seeds and persists the Steam persona when nothing is stored", async () => {
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
vi.spyOn(steamSDK, "getUser").mockResolvedValue({
steamId: "77",
name: "Ada",
});
const el = new UsernameInput();
el.connectedCallback();
await new Promise((r) => setTimeout(r, 0));
expect(el.getUsername()).toBe("Ada");
expect(localStorage.getItem("username")).toBe("Ada"); // usernameKey
});
it("keeps an already-stored username", async () => {
localStorage.setItem("username", "MyName");
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
vi.spyOn(steamSDK, "getUser").mockResolvedValue({
steamId: "77",
name: "Ada",
});
const el = new UsernameInput();
el.connectedCallback();
await new Promise((r) => setTimeout(r, 0));
expect(el.getUsername()).toBe("MyName");
});
it("strips brackets from the Steam persona", async () => {
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
vi.spyOn(steamSDK, "getUser").mockResolvedValue({
steamId: "77",
name: "[Ada]",
});
const el = new UsernameInput();
el.connectedCallback();
await new Promise((r) => setTimeout(r, 0));
expect(el.getUsername()).toBe("Ada");
});
it("keeps a valid generated name when the persona is invalid", async () => {
vi.spyOn(steamSDK, "isOnSteam").mockReturnValue(true);
vi.spyOn(steamSDK, "getUser").mockResolvedValue({
steamId: "77",
name: "x".repeat(100),
});
const el = new UsernameInput();
el.connectedCallback();
// Captured synchronously, before the async getUser() seed resolves: this is
// the generated anon name loadStoredUsername() just produced.
const generated = el.getUsername();
await new Promise((r) => setTimeout(r, 0));
// The invalid persona must be rejected and the exact generated name kept —
// not merely replaced by some other valid name.
expect(el.getUsername()).toBe(generated);
expect(generated).not.toBe("x".repeat(100));
expect(generated.length).toBeGreaterThan(0);
});
});
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import featuredStream from "../../../resources/featured-stream.json";
import { getFeaturedStream } from "../../../src/client/Api";
import { ClientEnv } from "../../../src/client/ClientEnv";
import { cornerFromCenter } from "../../../src/client/FeaturedStream";
import { FeaturedStreamSchema } from "../../../src/core/ApiSchemas";
@@ -65,10 +66,25 @@ describe("FeaturedStream", () => {
beforeEach(() => {
vi.unstubAllGlobals();
vi.spyOn(console, "warn").mockImplementation(() => {});
// getApiBase() → getAudience() now reads the JWT audience from
// BOOTSTRAP_CONFIG (ClientEnv), so getFeaturedStream needs it present or
// it throws before fetch and silently falls back. The value is irrelevant
// here since fetch is stubbed; we only need a well-formed config.
(window as any).BOOTSTRAP_CONFIG = {
gameEnv: "prod",
numWorkers: 1,
turnstileSiteKey: "x",
jwtAudience: "openfront.io",
instanceId: "desktop",
gitCommit: "test",
};
ClientEnv.reset();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
delete (window as any).BOOTSTRAP_CONFIG;
ClientEnv.reset();
});
const stubFetch = (impl: () => unknown) =>