Files
elysium/apps/web/src/api/client.ts
T
hikari d5284ff78c
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m11s
CI / Lint, Build & Test (pull_request) Successful in 1m14s
fix: suppress expired-token log noise and redirect expired sessions to login
- authMiddleware no longer logs token expiry as an error; only tampered
  or malformed tokens (genuinely suspicious) trigger logger.error
- fetchJson clears elysium_token and elysium_save_signature from
  localStorage and redirects to / on any 401, so players with expired
  sessions see the login page instead of a stuck error screen
2026-04-06 20:13:25 -07:00

380 lines
10 KiB
TypeScript

/**
* @file API client for communicating with the Elysium backend.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type {
AboutResponse,
ApotheosisRequest,
ApotheosisResponse,
AuthResponse,
BossChallengeRequest,
BossChallengeResponse,
BuyEchoUpgradeRequest,
BuyEchoUpgradeResponse,
BuyPrestigeUpgradeRequest,
BuyPrestigeUpgradeResponse,
CraftRecipeRequest,
CraftRecipeResponse,
ExploreClaimableResponse,
ExploreCollectRequest,
ExploreCollectResponse,
ExploreStartRequest,
ExploreStartResponse,
ForceUnlocksResponse,
LoadResponse,
PrestigeRequest,
PrestigeResponse,
PublicProfileResponse,
SaveRequest,
SaveResponse,
SyncNewContentResponse,
TranscendenceRequest,
TranscendenceResponse,
UpdateProfileRequest,
UpdateProfileResponse,
} from "@elysium/types";
const baseUrl = "/api";
/**
* Represents a 4xx API error so callers can distinguish expected server
* rejections from unexpected failures. ValidationErrors are downgraded to
* console.warn and are not forwarded to the error-email pipeline.
*/
class ValidationError extends Error {
public readonly statusCode: number;
/**
* Creates a new ValidationError.
* @param message - The error message from the server response.
* @param statusCode - The HTTP status code (4xx) returned by the server.
*/
public constructor(message: string, statusCode: number) {
super(message);
this.name = "ValidationError";
this.statusCode = statusCode;
}
}
const getToken = (): string | null => {
return globalThis.localStorage.getItem("elysium_token");
};
/* eslint-disable @typescript-eslint/naming-convention -- HTTP header names require specific casing */
const buildHeaders = (): Record<string, string> => {
const token = getToken();
return {
"Content-Type": "application/json",
...token !== null && token.length > 0
? { Authorization: `Bearer ${token}` }
: {},
};
};
/* eslint-enable @typescript-eslint/naming-convention -- HTTP header names require specific casing */
const fetchJson = async <T>(
path: string,
options?: RequestInit,
): Promise<T> => {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: { ...buildHeaders(), ...options?.headers },
});
if (!response.ok) {
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- JSON error response requires type assertion */
const errorBody = (await response.json().catch(() => {
return { error: "Unknown error" };
})) as Record<string, unknown>;
const message
= typeof errorBody.error === "string"
? errorBody.error
: "Unknown error";
if (response.status === 401) {
globalThis.localStorage.removeItem("elysium_token");
globalThis.localStorage.removeItem("elysium_save_signature");
globalThis.location.href = "/";
}
if (response.status >= 400 && response.status < 500) {
throw new ValidationError(message, response.status);
}
throw new Error(message);
}
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- JSON response requires type assertion */
return await (response.json() as Promise<T>);
};
/**
* Fetches the about information from the API.
* @returns The about response data.
*/
const getAbout = async(): Promise<AboutResponse> => {
return await fetchJson<AboutResponse>("/about");
};
/**
* Fetches the Discord OAuth URL from the API.
* @returns The authentication URL string.
*/
const getAuthUrl = async(): Promise<string> => {
const data = await fetchJson<{ url: string }>("/auth/url");
return data.url;
};
/**
* Handles the Discord OAuth callback and stores the auth token.
* @param code - The OAuth authorization code from Discord.
* @returns The authentication response data.
*/
const handleAuthCallback = async(code: string): Promise<AuthResponse> => {
const data = await fetchJson<AuthResponse>(`/auth/callback?code=${code}`);
globalThis.localStorage.setItem("elysium_token", data.token);
return data;
};
/**
* Loads the current game state from the server.
* @returns The load response containing the game state.
*/
const loadGame = async(): Promise<LoadResponse> => {
return await fetchJson<LoadResponse>("/game/load");
};
/**
* Resets all game progress on the server.
* @returns The load response after reset.
*/
const resetProgress = async(): Promise<LoadResponse> => {
return await fetchJson<LoadResponse>("/game/reset", { method: "POST" });
};
/**
* Saves the current game state to the server.
* @param body - The save request payload containing the game state.
* @returns The save response data.
*/
const saveGame = async(body: SaveRequest): Promise<SaveResponse> => {
return await fetchJson<SaveResponse>("/game/save", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Challenges a boss with the current game state.
* @param body - The boss challenge request payload.
* @returns The boss challenge response data.
*/
const challengeBoss = async(
body: BossChallengeRequest,
): Promise<BossChallengeResponse> => {
return await fetchJson<BossChallengeResponse>("/boss/challenge", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Triggers a prestige reset on the server.
* @param body - The prestige request payload.
* @returns The prestige response data.
*/
const prestige = async(body: PrestigeRequest): Promise<PrestigeResponse> => {
return await fetchJson<PrestigeResponse>("/prestige", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Purchases a prestige upgrade on the server.
* @param body - The buy prestige upgrade request payload.
* @returns The buy prestige upgrade response data.
*/
const buyPrestigeUpgrade = async(
body: BuyPrestigeUpgradeRequest,
): Promise<BuyPrestigeUpgradeResponse> => {
return await fetchJson<BuyPrestigeUpgradeResponse>("/prestige/buy-upgrade", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Triggers a transcendence reset on the server.
* @param body - The transcendence request payload.
* @returns The transcendence response data.
*/
const transcend = async(
body: TranscendenceRequest,
): Promise<TranscendenceResponse> => {
return await fetchJson<TranscendenceResponse>("/transcendence", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Purchases an echo upgrade on the server.
* @param body - The buy echo upgrade request payload.
* @returns The buy echo upgrade response data.
*/
const buyEchoUpgrade = async(
body: BuyEchoUpgradeRequest,
): Promise<BuyEchoUpgradeResponse> => {
return await fetchJson<BuyEchoUpgradeResponse>("/transcendence/buy-upgrade", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Triggers an apotheosis reset on the server.
* @param body - The apotheosis request payload.
* @returns The apotheosis response data.
*/
const achieveApotheosis = async(
body: ApotheosisRequest,
): Promise<ApotheosisResponse> => {
return await fetchJson<ApotheosisResponse>("/apotheosis", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Starts an exploration in a given area.
* @param body - The exploration start request payload.
* @returns The exploration start response data.
*/
const startExploration = async(
body: ExploreStartRequest,
): Promise<ExploreStartResponse> => {
return await fetchJson<ExploreStartResponse>("/explore/start", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Collects the rewards from a completed exploration.
* @param body - The exploration collect request payload.
* @returns The exploration collect response data.
*/
const collectExploration = async(
body: ExploreCollectRequest,
): Promise<ExploreCollectResponse> => {
return await fetchJson<ExploreCollectResponse>("/explore/collect", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Checks whether a given exploration area is ready to claim on the server.
* @param areaId - The area ID to check.
* @returns Whether the exploration is claimable.
*/
const checkExplorationClaimable = async(
areaId: string,
): Promise<ExploreClaimableResponse> => {
return await fetchJson<ExploreClaimableResponse>(
`/explore/claimable?areaId=${encodeURIComponent(areaId)}`,
);
};
/**
* Crafts a recipe on the server.
* @param body - The craft recipe request payload.
* @returns The craft recipe response data.
*/
const craftRecipe = async(
body: CraftRecipeRequest,
): Promise<CraftRecipeResponse> => {
return await fetchJson<CraftRecipeResponse>("/craft", {
body: JSON.stringify(body),
method: "POST",
});
};
/**
* Sends a request to fix any missing unlocks in the player's game state.
* @returns The corrected game state and counts of what was unlocked.
*/
const forceUnlocks = async(): Promise<ForceUnlocksResponse> => {
return await fetchJson<ForceUnlocksResponse>("/debug/force-unlocks", {
method: "POST",
});
};
/**
* Syncs any content added after the player's save was created into their save.
* @returns The updated game state and counts of what was added per content type.
*/
const syncNewContent = async(): Promise<SyncNewContentResponse> => {
return await fetchJson<SyncNewContentResponse>("/debug/sync-new-content", {
method: "POST",
});
};
/**
* Performs a complete hard reset of the player's game state via the debug endpoint.
* @returns The fresh game state as a LoadResponse.
*/
const debugHardReset = async(): Promise<LoadResponse> => {
return await fetchJson<LoadResponse>("/debug/hard-reset", { method: "POST" });
};
/**
* Fetches a public player profile by Discord ID.
* @param discordId - The Discord ID of the player to look up.
* @returns The public profile response data.
*/
const getPublicProfile = async(
discordId: string,
): Promise<PublicProfileResponse> => {
return await fetchJson<PublicProfileResponse>(`/profile/${discordId}`);
};
/**
* Updates the current player's profile.
* @param body - The update profile request payload.
* @returns The update profile response data.
*/
const updateProfile = async(
body: UpdateProfileRequest,
): Promise<UpdateProfileResponse> => {
return await fetchJson<UpdateProfileResponse>("/profile", {
body: JSON.stringify(body),
method: "PUT",
});
};
export {
ValidationError,
achieveApotheosis,
buyEchoUpgrade,
buyPrestigeUpgrade,
challengeBoss,
checkExplorationClaimable,
collectExploration,
craftRecipe,
debugHardReset,
forceUnlocks,
syncNewContent,
getAbout,
getAuthUrl,
getPublicProfile,
handleAuthCallback,
loadGame,
prestige,
resetProgress,
saveGame,
startExploration,
transcend,
updateProfile,
};