This commit is contained in:
Ryan Barlow
2026-05-25 18:58:07 +01:00
parent 08528b7cfa
commit 4cf234fb7a
14 changed files with 900 additions and 191 deletions
+25 -7
View File
@@ -1,3 +1,7 @@
import {
ClanExistsResponseSchema,
clanExistsApiPath,
} from "../core/ApiSchemas";
import {
type ClanBansResponse,
ClanBansResponseSchema,
@@ -18,6 +22,8 @@ import {
} from "../core/ClanApiSchemas";
import { getApiBase } from "./Api";
import { getAuthHeader } from "./Auth";
const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000;
export type {
ClanBan,
ClanBansResponse,
@@ -30,9 +36,9 @@ export type {
ClanInfo,
ClanJoinRequest,
ClanMember,
ClanMembersResponse,
ClanMemberStats,
ClanMemberWL,
ClanMembersResponse,
ClanRequestsResponse,
} from "../core/ClanApiSchemas";
@@ -127,14 +133,26 @@ export async function fetchClanDetail(tag: string): Promise<ClanInfo | false> {
// Lightweight existence probe. Public endpoint, no auth required — used to
// detect clan-tag ownership conflicts when a user types a tag into the input.
// Returns null on unexpected statuses so callers can fail open.
// Returns null on unexpected statuses, timeouts, or transport errors so callers
// can fail open.
export async function fetchClanExists(tag: string): Promise<boolean | null> {
try {
const res = await fetch(
`${getApiBase()}/public/clan/${encodeURIComponent(tag)}/exists`,
{ headers: { Accept: "application/json" } },
);
if (res.status === 200) return true;
const res = await fetch(`${getApiBase()}${clanExistsApiPath(tag)}`, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS),
});
if (res.status === 200) {
try {
const text = await res.text();
if (text.length > 0) {
const parsed = ClanExistsResponseSchema.safeParse(JSON.parse(text));
if (parsed.success && parsed.data?.exists === false) return false;
}
} catch {
// Body parsing is forward-compat only; ignore failures.
}
return true;
}
if (res.status === 404) return false;
return null;
} catch {
+207
View File
@@ -0,0 +1,207 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { sanitizeClanTag } from "../core/Util";
import {
MAX_CLAN_TAG_LENGTH,
MIN_CLAN_TAG_LENGTH,
validateClanTag,
} from "../core/validations/username";
import { getUserMe } from "./Api";
import { fetchClanExists } from "./ClanApi";
const CLAN_OWNERSHIP_DEBOUNCE_MS = 400;
const clanTagKey = "clanTag";
interface LangSelectorLike {
currentLang?: string;
translations?: Record<string, string>;
defaultTranslations?: Record<string, string>;
}
@customElement("clan-tag-input")
export class ClanTagInput extends LitElement {
@state() private clanTag: string = "";
@property({ type: String }) validationError: string = "";
private formatError: string = "";
private ownershipError: string = "";
private checkCounter: number = 0;
private checkTimer: ReturnType<typeof setTimeout> | null = null;
private lastTranslatedLang: string | null = null;
createRenderRoot() {
return this;
}
public isValid(): boolean {
return this.formatError === "" && this.ownershipError === "";
}
public getValue(): string | null {
return this.clanTag.length >= MIN_CLAN_TAG_LENGTH &&
this.clanTag.length <= MAX_CLAN_TAG_LENGTH &&
validateClanTag(this.clanTag).isValid
? this.clanTag
: null;
}
connectedCallback() {
super.connectedCallback();
this.clanTag = localStorage.getItem(clanTagKey) ?? "";
this.validate();
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.checkTimer !== null) {
clearTimeout(this.checkTimer);
this.checkTimer = null;
}
this.checkCounter++; // cancel any in-flight async check
}
protected updated(): void {
// Re-validate when translations finish loading so the initial error
// (which may have been built from raw keys) gets re-translated.
if (!this.validationError) return;
const ls = document.querySelector<LangSelectorLike & Element>(
"lang-selector",
);
const lang = ls?.currentLang;
const hasTranslations = ls?.translations ?? ls?.defaultTranslations;
if (hasTranslations && lang && lang !== this.lastTranslatedLang) {
this.lastTranslatedLang = lang;
this.validate();
}
}
render() {
return html`
<div class="relative flex items-center h-full">
<input
type="text"
.value=${this.clanTag}
@input=${this.handleInput}
placeholder="${translateText("username.tag")}"
minlength="${MIN_CLAN_TAG_LENGTH}"
maxlength="${MAX_CLAN_TAG_LENGTH}"
class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
/>
${this.validationError
? html`<div
id="clan-tag-validation-error"
class="absolute top-full left-0 z-50 mt-1 px-3 py-2 text-sm font-medium border border-red-500/50 rounded-lg bg-red-900/90 text-red-200 backdrop-blur-md shadow-lg whitespace-nowrap"
>
${this.validationError}
</div>`
: null}
</div>
`;
}
private handleInput(e: Event) {
const input = e.target as HTMLInputElement;
const sanitized = sanitizeClanTag(input.value);
if (input.value.toUpperCase() !== sanitized) {
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: translateText("username.tag_invalid_chars"),
color: "red",
duration: 2000,
},
}),
);
}
input.value = sanitized;
this.clanTag = sanitized;
this.validate();
}
private validate() {
const tag = this.clanTag;
const result = validateClanTag(tag);
this.formatError = result.isValid ? "" : (result.error ?? "");
// Cancel any pending/in-flight ownership check.
if (this.checkTimer !== null) clearTimeout(this.checkTimer);
this.checkTimer = null;
this.checkCounter++;
if (!result.isValid || tag.length === 0) {
// Nothing to ask the server about — clear any old ownership error,
// and remember the cleared/short value across reloads.
this.ownershipError = "";
if (result.isValid) localStorage.setItem(clanTagKey, "");
} else {
this.checkTimer = setTimeout(() => {
this.checkTimer = null;
void this.checkOwnership(tag);
}, CLAN_OWNERSHIP_DEBOUNCE_MS);
}
this.refreshError();
}
// Are you a member? If not, does the clan exist? If it doesn't (fictional)
// or the check fails open, accept. Otherwise reject.
private async checkOwnership(tag: string) {
const checkId = this.checkCounter;
const stillCurrent = () =>
checkId === this.checkCounter && this.clanTag === tag;
const me = await getUserMe();
if (!stillCurrent()) return;
const myTags = me
? (me.player.clans ?? []).map((c) => c.tag.toUpperCase())
: [];
if (!myTags.includes(tag.toUpperCase())) {
const exists = await fetchClanExists(tag);
if (!stillCurrent()) return;
if (exists === true) {
this.reject(tag);
return;
}
}
this.accept(tag);
}
private accept(tag: string) {
this.ownershipError = "";
localStorage.setItem(clanTagKey, tag);
this.refreshError();
}
private reject(tag: string) {
this.ownershipError = translateText("username.tag_not_member", { tag });
localStorage.removeItem(clanTagKey);
this.refreshError();
}
private refreshError() {
const next = this.formatError || this.ownershipError;
if (this.validationError !== next) {
this.validationError = next;
this.requestUpdate();
}
}
public showValidationFeedback() {
const message =
this.validationError || translateText("username.tag_invalid_chars");
window.dispatchEvent(
new CustomEvent("show-message", {
detail: { message, color: "red", duration: 2500 },
}),
);
}
public validateOrShowError(): boolean {
if (this.isValid()) return true;
this.showValidationFeedback();
return false;
}
}
+7 -2
View File
@@ -10,6 +10,7 @@ import {
Trios,
} from "../core/game/Game";
import { PublicGameInfo, PublicGames } from "../core/Schemas";
import { ClanTagInput } from "./ClanTagInput";
import "./components/IOSAddToHomeScreenBanner";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { HostLobbyModal } from "./HostLobbyModal";
@@ -46,10 +47,14 @@ export class GameModeSelector extends LitElement {
}
/**
* Validates username input and shows error message if invalid.
* Returns true if valid, false otherwise.
* Validates username and clan tag inputs and shows error messages if invalid.
* Returns true if both are valid, false otherwise.
*/
private validateUsername(): boolean {
const clanTagInput = document.querySelector(
"clan-tag-input",
) as ClanTagInput | null;
if (clanTagInput && !clanTagInput.validateOrShowError()) return false;
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput | null;
+1
View File
@@ -221,6 +221,7 @@ export class LangSelector extends LitElement {
"help-modal",
"settings-modal",
"username-input",
"clan-tag-input",
"game-mode-selector",
"user-setting",
"o-modal",
+20 -1
View File
@@ -21,6 +21,8 @@ import "./AccountModal";
import { getUserMe, invalidateUserMe } from "./Api";
import { userAuth } from "./Auth";
import "./ClanModal";
import "./ClanTagInput";
import { ClanTagInput } from "./ClanTagInput";
import { joinLobby, type JoinLobbyResult } from "./ClientGameRunner";
import { getPlayerCosmeticsRefs } from "./Cosmetics";
import { crazyGamesSDK } from "./CrazyGamesSDK";
@@ -251,6 +253,7 @@ class Client {
private currentUrl: string | null = null;
private usernameInput: UsernameInput | null = null;
private clanTagInput: ClanTagInput | null = null;
private flagInput: FlagInput | null = null;
private hostModal: HostPrivateLobbyModal;
@@ -361,6 +364,13 @@ class Client {
console.warn("Username input element not found");
}
this.clanTagInput = document.querySelector(
"clan-tag-input",
) as ClanTagInput;
if (!this.clanTagInput) {
console.warn("Clan tag input element not found");
}
this.gameModeSelector = document.querySelector(
"game-mode-selector",
) as GameModeSelector;
@@ -814,6 +824,9 @@ class Client {
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
const lobby = event.detail;
this.mostRecentJoinEvent = event.timeStamp;
if (this.clanTagInput && !this.clanTagInput.validateOrShowError()) {
return;
}
if (this.usernameInput && !this.usernameInput.validateOrShowError()) {
return;
}
@@ -841,7 +854,7 @@ class Client {
cosmetics: await getPlayerCosmeticsRefs(),
turnstileToken: await this.getTurnstileToken(lobby),
playerName: this.usernameInput?.getUsername() ?? genAnonUsername(),
playerClanTag: this.usernameInput?.getClanTag() ?? null,
playerClanTag: this.clanTagInput?.getValue() ?? null,
playerRole,
gameStartInfo: lobby.gameStartInfo ?? lobby.gameRecord?.info,
gameRecord: lobby.gameRecord,
@@ -862,6 +875,12 @@ class Client {
// fix edge case where username-validation-error is re-rendered and hidden tag removed
this.usernameInput.validationError = "";
}
if (this.clanTagInput) {
this.clanTagInput.validationError = "";
}
document
.getElementById("clan-tag-validation-error")
?.classList.add("hidden");
document
.getElementById("username-validation-error")
?.classList.add("hidden");
+5 -1
View File
@@ -14,6 +14,7 @@ import {
import { TeamCountConfig } from "../core/Schemas";
import { generateID } from "../core/Util";
import { hasLinkedAccount } from "./Api";
import { ClanTagInput } from "./ClanTagInput";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import { BaseModal } from "./components/BaseModal";
@@ -646,6 +647,9 @@ export class SinglePlayerModal extends BaseModal {
const usernameInput = document.querySelector(
"username-input",
) as UsernameInput;
const clanTagInput = document.querySelector(
"clan-tag-input",
) as ClanTagInput | null;
await crazyGamesSDK.requestMidgameAd();
@@ -659,7 +663,7 @@ export class SinglePlayerModal extends BaseModal {
{
clientID,
username: usernameInput.getUsername(),
clanTag: usernameInput.getClanTag() ?? null,
clanTag: clanTagInput?.getValue() ?? null,
cosmetics: await getPlayerCosmetics(),
},
],
+16 -172
View File
@@ -1,21 +1,13 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { generateCryptoRandomUUID, translateText } from "../client/Utils";
import { sanitizeClanTag } from "../core/Util";
import {
MAX_CLAN_TAG_LENGTH,
MAX_USERNAME_LENGTH,
MIN_CLAN_TAG_LENGTH,
MIN_USERNAME_LENGTH,
validateClanTag,
validateUsername,
} from "../core/validations/username";
import { getUserMe } from "./Api";
import { fetchClanExists } from "./ClanApi";
import { crazyGamesSDK } from "./CrazyGamesSDK";
const CLAN_OWNERSHIP_DEBOUNCE_MS = 400;
interface LangSelectorLike {
currentLang?: string;
translations?: Record<string, string>;
@@ -23,23 +15,14 @@ interface LangSelectorLike {
}
const usernameKey: string = "username";
const clanTagKey: string = "clanTag";
@customElement("username-input")
export class UsernameInput extends LitElement {
@state() private baseUsername: string = "";
@state() private clanTag: string = "";
@property({ type: String }) validationError: string = "";
private _isValid: boolean = true;
private _lastValidatedLang: string | null = null;
private syncValidationError: string = "";
private syncIsValid: boolean = true;
private clanTagAsyncError: string = "";
private clanTagCheckCounter: number = 0;
private clanTagCheckTimer: ReturnType<typeof setTimeout> | null = null;
// Remove static styles since we're using Tailwind
createRenderRoot() {
// Disable shadow DOM to allow Tailwind classes to work
@@ -50,14 +33,6 @@ export class UsernameInput extends LitElement {
return this.baseUsername.trim();
}
public getClanTag(): string | null {
return this.clanTag.length >= MIN_CLAN_TAG_LENGTH &&
this.clanTag.length <= MAX_CLAN_TAG_LENGTH &&
validateClanTag(this.clanTag).isValid
? this.clanTag
: null;
}
connectedCallback() {
super.connectedCallback();
this.loadStoredUsername();
@@ -95,7 +70,6 @@ export class UsernameInput extends LitElement {
private loadStoredUsername() {
const storedUsername = localStorage.getItem(usernameKey);
if (storedUsername) {
this.clanTag = localStorage.getItem(clanTagKey) ?? "";
this.baseUsername = storedUsername;
this.validateAndStore();
} else {
@@ -106,16 +80,7 @@ export class UsernameInput extends LitElement {
render() {
return html`
<div class="flex items-center w-full h-full gap-2">
<input
type="text"
.value=${this.clanTag}
@input=${this.handleClanTagChange}
placeholder="${translateText("username.tag")}"
minlength="${MIN_CLAN_TAG_LENGTH}"
maxlength="${MAX_CLAN_TAG_LENGTH}"
class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
/>
<div class="relative w-full h-full">
<input
type="text"
.value=${this.baseUsername}
@@ -123,52 +88,26 @@ export class UsernameInput extends LitElement {
placeholder="${translateText("username.enter_username")}"
minlength="${MIN_USERNAME_LENGTH}"
maxlength="${MAX_USERNAME_LENGTH}"
class="flex-1 min-w-0 border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
class="w-full h-full border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
/>
${this.validationError
? html`<div
id="username-validation-error"
class="absolute top-full left-0 z-50 w-full mt-1 px-3 py-2 text-sm font-medium border border-red-500/50 rounded-lg bg-red-900/90 text-red-200 backdrop-blur-md shadow-lg"
>
${this.validationError}
</div>`
: null}
</div>
${this.validationError
? html`<div
id="username-validation-error"
class="absolute top-full left-0 z-50 w-full mt-1 px-3 py-2 text-sm font-medium border border-red-500/50 rounded-lg bg-red-900/90 text-red-200 backdrop-blur-md shadow-lg"
>
${this.validationError}
</div>`
: null}
`;
}
private handleClanTagChange(e: Event) {
const input = e.target as HTMLInputElement;
const originalValue = input.value;
const val = sanitizeClanTag(originalValue);
// Only show toast if characters were actually removed (not just uppercased)
if (originalValue.toUpperCase() !== val) {
input.value = val;
// Show toast when invalid characters are removed
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: translateText("username.tag_invalid_chars"),
color: "red",
duration: 2000,
},
}),
);
} else if (originalValue !== val) {
// Just update the input without toast if only case changed
input.value = val;
}
this.clanTag = val;
this.validateAndStore();
}
private handleUsernameChange(e: Event) {
const input = e.target as HTMLInputElement;
const originalValue = input.value;
const val = originalValue.replace(/[[\]]/g, "");
if (originalValue !== val) {
input.value = val;
// Show toast when brackets are removed
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
@@ -185,109 +124,14 @@ export class UsernameInput extends LitElement {
private validateAndStore() {
const trimmedBase = this.getUsername();
const clanTagResult = validateClanTag(this.clanTag);
if (!clanTagResult.isValid) {
this.syncIsValid = false;
this.syncValidationError = clanTagResult.error ?? "";
const result = validateUsername(trimmedBase);
this._isValid = result.isValid;
if (result.isValid) {
localStorage.setItem(usernameKey, trimmedBase);
this.validationError = "";
} else {
const result = validateUsername(trimmedBase);
this.syncIsValid = result.isValid;
if (result.isValid) {
localStorage.setItem(usernameKey, trimmedBase);
// clanTag is persisted by scheduleClanTagOwnershipCheck (or its async
// continuation) so we never store a tag the server would reject.
this.syncValidationError = "";
} else {
this.syncValidationError = result.error ?? "";
}
this.validationError = result.error ?? "";
}
this.scheduleClanTagOwnershipCheck();
this.updateValidationState();
}
private persistClanTag(tag: string) {
if (this.syncIsValid) {
localStorage.setItem(clanTagKey, tag);
}
}
private updateValidationState() {
if (!this.syncIsValid) {
this._isValid = false;
this.validationError = this.syncValidationError;
return;
}
if (this.clanTagAsyncError) {
this._isValid = false;
this.validationError = this.clanTagAsyncError;
return;
}
this._isValid = true;
this.validationError = "";
}
private scheduleClanTagOwnershipCheck() {
if (this.clanTagCheckTimer !== null) {
clearTimeout(this.clanTagCheckTimer);
this.clanTagCheckTimer = null;
}
const tag = this.clanTag;
if (
tag.length < MIN_CLAN_TAG_LENGTH ||
tag.length > MAX_CLAN_TAG_LENGTH ||
!validateClanTag(tag).isValid
) {
// Bump the counter so any in-flight check is discarded.
this.clanTagCheckCounter++;
if (this.clanTagAsyncError) {
this.clanTagAsyncError = "";
}
// No async check needed — persist the (empty/short) value so clearing
// the tag is remembered across reloads.
this.persistClanTag(this.getClanTag() ?? "");
return;
}
this.clanTagCheckTimer = setTimeout(() => {
this.clanTagCheckTimer = null;
void this.runClanTagOwnershipCheck(tag);
}, CLAN_OWNERSHIP_DEBOUNCE_MS);
}
private async runClanTagOwnershipCheck(expectedTag: string) {
const checkId = ++this.clanTagCheckCounter;
const stillCurrent = () =>
checkId === this.clanTagCheckCounter && this.clanTag === expectedTag;
const me = await getUserMe();
if (!stillCurrent()) return;
if (me) {
const myTags = (me.player.clans ?? []).map((c) => c.tag.toUpperCase());
if (myTags.includes(expectedTag.toUpperCase())) {
this.setClanTagAsyncError("");
this.persistClanTag(expectedTag);
return;
}
}
const exists = await fetchClanExists(expectedTag);
if (!stillCurrent()) return;
if (exists === true) {
this.setClanTagAsyncError(
translateText("username.tag_not_member", { tag: expectedTag }),
);
} else {
this.setClanTagAsyncError("");
this.persistClanTag(expectedTag);
}
}
private setClanTagAsyncError(error: string) {
if (this.clanTagAsyncError === error) return;
this.clanTagAsyncError = error;
this.updateValidationState();
this.requestUpdate();
}
public isValid(): boolean {
+3
View File
@@ -82,6 +82,9 @@ export class PlayPage extends LitElement {
class="px-2 py-2 bg-surface border-y border-white/10 overflow-visible lg:flex lg:items-center lg:gap-x-2 lg:h-[60px] lg:p-3 lg:relative lg:z-20 lg:border-y-0 lg:rounded-xl"
>
<div class="flex items-center gap-2 min-w-0 w-full">
<clan-tag-input
class="shrink-0 h-10 lg:h-[50px]"
></clan-tag-input>
<username-input
class="flex-1 min-w-0 h-10 lg:h-[50px]"
></username-input>
+19
View File
@@ -21,6 +21,25 @@ export const RefreshResponseSchema = z.object({
});
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
// Auth API path for the clan existence probe. Uppercased here so the URL
// matches the canonical tag form (membership checks also uppercase), avoiding
// case-sensitivity mismatches against the upstream endpoint.
export function clanExistsApiPath(tag: string): string {
return `/public/clan/${encodeURIComponent(tag.toUpperCase())}/exists`;
}
// The upstream contract uses HTTP status alone (200 = exists, 404 = not).
// This schema is kept for forward-compat in case a body is added; today it
// matches an empty/absent body too.
export const ClanExistsResponseSchema = z
.object({
exists: z.boolean().optional(),
})
.partial()
.or(z.null())
.or(z.undefined());
export type ClanExistsResponse = z.infer<typeof ClanExistsResponseSchema>;
export const TokenPayloadSchema = z.object({
jti: z.string(),
sub: z
+80 -8
View File
@@ -1,6 +1,8 @@
import { jwtVerify } from "jose";
import { z } from "zod";
import {
clanExistsApiPath,
ClanExistsResponseSchema,
TokenPayload,
TokenPayloadSchema,
UserMeResponse,
@@ -8,8 +10,14 @@ import {
} from "../core/ApiSchemas";
import { GameEnv } from "../core/configuration/Config";
import { PersistentIdSchema } from "../core/Schemas";
import { logger } from "./Logger";
import { ServerEnv } from "./ServerEnv";
const log = logger.child({ comp: "jwt" });
const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000;
const CLAN_EXISTS_CACHE_TTL_MS = 60_000;
type TokenVerificationResult =
| {
type: "success";
@@ -69,9 +77,9 @@ export async function getUserMe(
| { type: "error"; message: string }
> {
try {
// Get the user object
const response = await fetch(ServerEnv.jwtIssuer() + "/users/@me", {
headers: {
Accept: "application/json",
authorization: `Bearer ${token}`,
"x-api-key": ServerEnv.apiKey(),
},
@@ -99,17 +107,81 @@ export async function getUserMe(
}
}
// Module-level TTL cache. Clan existence is stable, so a short cache prevents
// repeated upstream calls during lobby-start surges.
const clanExistsCache = new Map<
string,
{ result: boolean; expiresAt: number }
>();
function cacheGet(key: string): boolean | undefined {
const entry = clanExistsCache.get(key);
if (entry === undefined) return undefined;
if (Date.now() >= entry.expiresAt) {
clanExistsCache.delete(key);
return undefined;
}
return entry.result;
}
function cacheSet(key: string, result: boolean) {
clanExistsCache.set(key, {
result,
expiresAt: Date.now() + CLAN_EXISTS_CACHE_TTL_MS,
});
}
// For tests.
export function _clearClanExistsCacheForTest() {
clanExistsCache.clear();
}
// Best-effort check: does a clan with this tag exist?
// Returns null on transport errors or unexpected statuses so callers can
// fail open — the goal is impersonation prevention, not availability blocker.
// Returns null on transport errors, timeouts, or unexpected statuses so callers
// can fail open — the goal is impersonation prevention, not an availability
// blocker. Logs a warn on unexpected statuses so outages are observable.
export async function clanExistsByTag(tag: string): Promise<boolean | null> {
const cacheKey = tag.toUpperCase();
const cached = cacheGet(cacheKey);
if (cached !== undefined) return cached;
try {
const url = `${ServerEnv.jwtIssuer()}/public/clan/${encodeURIComponent(tag)}/exists`;
const response = await fetch(url);
if (response.status === 200) return true;
if (response.status === 404) return false;
const url = `${ServerEnv.jwtIssuer()}${clanExistsApiPath(tag)}`;
const response = await fetch(url, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS),
});
if (response.status === 200) {
// The upstream may eventually start returning a body; tolerate either.
try {
const text = await response.text();
if (text.length > 0) {
const parsed = ClanExistsResponseSchema.safeParse(JSON.parse(text));
if (parsed.success && parsed.data?.exists === false) {
cacheSet(cacheKey, false);
return false;
}
}
} catch {
// Body parsing is forward-compat only; ignore failures.
}
cacheSet(cacheKey, true);
return true;
}
if (response.status === 404) {
cacheSet(cacheKey, false);
return false;
}
log.warn("clanExistsByTag: unexpected status, failing open", {
tag: cacheKey,
status: response.status,
});
return null;
} catch {
} catch (e) {
log.warn("clanExistsByTag: fetch failed, failing open", {
tag: cacheKey,
error: e instanceof Error ? e.message : String(e),
});
return null;
}
}
+203
View File
@@ -0,0 +1,203 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
function createMockLocalStorage(): Storage {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] ?? null,
setItem: (key: string, value: string) => {
store[key] = String(value);
},
removeItem: (key: string) => {
delete store[key];
},
clear: () => {
store = {};
},
get length() {
return Object.keys(store).length;
},
key: (index: number) => Object.keys(store)[index] ?? null,
};
}
vi.mock("../../src/client/Api", () => ({
getUserMe: vi.fn(),
}));
vi.mock("../../src/client/ClanApi", () => ({
fetchClanExists: vi.fn(),
}));
import { getUserMe } from "../../src/client/Api";
import { fetchClanExists } from "../../src/client/ClanApi";
import { ClanTagInput } from "../../src/client/ClanTagInput";
const flushPromises = async () => {
for (let i = 0; i < 5; i++) {
await Promise.resolve();
}
};
beforeEach(() => {
vi.useFakeTimers();
vi.mocked(getUserMe).mockReset();
vi.mocked(fetchClanExists).mockReset();
vi.stubGlobal("localStorage", createMockLocalStorage());
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("ClanTagInput async ownership check", () => {
it("surfaces tag_not_member when user is not a member and clan exists", async () => {
vi.mocked(getUserMe).mockResolvedValue({
user: {},
player: {
publicId: "p1",
adfree: false,
achievements: { singleplayerMap: [] },
friends: [],
subscription: null,
clans: [],
},
} as any);
vi.mocked(fetchClanExists).mockResolvedValue(true);
const input = new ClanTagInput();
(input as any).clanTag = "ABC";
(input as any).validate();
vi.advanceTimersByTime(401);
await flushPromises();
await flushPromises();
expect(input.isValid()).toBe(false);
expect((input as any).ownershipError).toBe("username.tag_not_member");
});
it("clears any stored clanTag when async detects ownership conflict", async () => {
vi.mocked(getUserMe).mockResolvedValue({
user: {},
player: {
publicId: "p1",
adfree: false,
achievements: { singleplayerMap: [] },
friends: [],
subscription: null,
clans: [],
},
} as any);
vi.mocked(fetchClanExists).mockResolvedValue(true);
localStorage.setItem("clanTag", "ABC");
const input = new ClanTagInput();
(input as any).clanTag = "ABC";
(input as any).validate();
vi.advanceTimersByTime(401);
await flushPromises();
await flushPromises();
expect(localStorage.getItem("clanTag")).toBeNull();
});
it("keeps the tag when the clan does not exist (fictional)", async () => {
vi.mocked(getUserMe).mockResolvedValue(false);
vi.mocked(fetchClanExists).mockResolvedValue(false);
const input = new ClanTagInput();
(input as any).clanTag = "FIC";
(input as any).validate();
vi.advanceTimersByTime(401);
await flushPromises();
await flushPromises();
expect(input.isValid()).toBe(true);
expect(localStorage.getItem("clanTag")).toBe("FIC");
});
it("fails open: keeps the tag when existence check returns null", async () => {
vi.mocked(getUserMe).mockResolvedValue(false);
vi.mocked(fetchClanExists).mockResolvedValue(null);
const input = new ClanTagInput();
(input as any).clanTag = "ABC";
(input as any).validate();
vi.advanceTimersByTime(401);
await flushPromises();
await flushPromises();
expect(input.isValid()).toBe(true);
});
it("discards stale async results when the tag has changed", async () => {
let resolveFirst!: (v: boolean | null) => void;
let resolveSecond!: (v: boolean | null) => void;
const first = new Promise<boolean | null>((r) => (resolveFirst = r));
const second = new Promise<boolean | null>((r) => (resolveSecond = r));
vi.mocked(getUserMe).mockResolvedValue(false);
vi.mocked(fetchClanExists)
.mockReturnValueOnce(first)
.mockReturnValueOnce(second);
const input = new ClanTagInput();
(input as any).clanTag = "AAA";
(input as any).validate();
vi.advanceTimersByTime(401);
await flushPromises();
// Now the user switches to a different tag before the first response lands.
(input as any).clanTag = "BBB";
(input as any).validate();
vi.advanceTimersByTime(401);
await flushPromises();
// First (stale) response would have said "AAA exists" → conflict, but the
// tag is no longer AAA, so this must NOT clobber the result for BBB.
resolveFirst(true);
await flushPromises();
expect((input as any).ownershipError).toBe("");
// Second response says BBB doesn't exist → fictional, accept.
resolveSecond(false);
await flushPromises();
await flushPromises();
expect(input.isValid()).toBe(true);
expect(localStorage.getItem("clanTag")).toBe("BBB");
});
it("clears the pending timer in disconnectedCallback", () => {
vi.mocked(getUserMe).mockResolvedValue(false);
vi.mocked(fetchClanExists).mockResolvedValue(false);
const input = new ClanTagInput();
(input as any).clanTag = "ABC";
(input as any).validate();
expect((input as any).checkTimer).not.toBeNull();
(input as any).disconnectedCallback();
expect((input as any).checkTimer).toBeNull();
});
it("getValue returns null for empty/short/invalid tags and the tag when valid", () => {
const input = new ClanTagInput();
(input as any).clanTag = "";
expect(input.getValue()).toBeNull();
(input as any).clanTag = "A";
expect(input.getValue()).toBeNull();
(input as any).clanTag = "TOOLONG";
expect(input.getValue()).toBeNull();
(input as any).clanTag = "ABC";
expect(input.getValue()).toBe("ABC");
});
});
+61
View File
@@ -10,6 +10,7 @@ vi.mock("../../../src/client/Auth", () => ({
import {
fetchClanDetail,
fetchClanExists,
fetchClanGames,
fetchClanLeaderboard,
fetchClanMembers,
@@ -72,6 +73,66 @@ describe("fetchClanLeaderboard", () => {
});
});
describe("fetchClanExists", () => {
const okStatus = (status: number, body: string = "") => ({
status,
text: async () => body,
});
it("returns true on HTTP 200", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(okStatus(200))),
);
await expect(fetchClanExists("ABC")).resolves.toBe(true);
});
it("returns false on HTTP 404", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(okStatus(404))),
);
await expect(fetchClanExists("XYZ")).resolves.toBe(false);
});
it("returns null on unexpected status (5xx)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(okStatus(503))),
);
await expect(fetchClanExists("ABC")).resolves.toBeNull();
});
it("returns null on transport error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
await expect(fetchClanExists("ABC")).resolves.toBeNull();
});
it("uppercases the tag in the URL", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(okStatus(200)),
);
vi.stubGlobal("fetch", fetchSpy);
await fetchClanExists("abc");
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
expect(calledUrl).toContain("/public/clan/ABC/exists");
});
it("treats a body {exists:false} as false on 200", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve(okStatus(200, JSON.stringify({ exists: false }))),
),
);
await expect(fetchClanExists("ABC")).resolves.toBe(false);
});
});
describe("fetchClanDetail", () => {
const clanInfo = {
name: "Test Clan",
+112
View File
@@ -14,6 +14,7 @@ vi.mock("../../src/core/Schemas", async () => {
});
import { GameType } from "../../src/core/game/Game";
import { Client } from "../../src/server/Client";
import { GameServer } from "../../src/server/GameServer";
describe("GameLifecycle", () => {
@@ -86,3 +87,114 @@ describe("GameLifecycle", () => {
expect((game as any)._hasEnded).toBe(true);
});
});
describe("GameServer.rejoinClient — clanTag identityUpdate", () => {
let mockLogger: any;
const mkWs = (): any => ({
readyState: 1, // OPEN
on: vi.fn(),
send: vi.fn(),
close: vi.fn(),
removeAllListeners: vi.fn(),
});
beforeEach(() => {
mockLogger = {
child: vi.fn().mockReturnThis(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
});
afterEach(() => {
vi.restoreAllMocks();
});
const seedClient = (game: GameServer, clanTag: string | null) => {
const ws = mkWs();
const client = new Client(
"cid-1",
"pid-1",
null,
null,
undefined,
"127.0.0.1",
"tester",
clanTag,
ws,
undefined,
undefined,
[],
);
// Seed internals as if the client had joined normally.
(game as any).activeClients.push(client);
(game as any).allClients.set(client.clientID, client);
(game as any).persistentIdToClientId.set(
client.persistentID,
client.clientID,
);
(game as any).websockets.add(ws);
return client;
};
it("preserves clanTag on reconnect when identityUpdate omits it", () => {
const game = new GameServer("g-1", mockLogger, Date.now(), {
gameType: GameType.Private,
} as any);
const client = seedClient(game, "ABC");
const newWs = mkWs();
const ok = game.rejoinClient(newWs as any, "pid-1", 0, {
username: "renamed",
});
expect(ok).toBe(true);
expect(client.clanTag).toBe("ABC");
expect(client.username).toBe("renamed");
});
it("clears clanTag on reconnect when identityUpdate passes null", () => {
const game = new GameServer("g-2", mockLogger, Date.now(), {
gameType: GameType.Private,
} as any);
const client = seedClient(game, "ABC");
game.rejoinClient(mkWs() as any, "pid-1", 0, {
username: "tester",
clanTag: null,
});
expect(client.clanTag).toBeNull();
});
it("updates clanTag on reconnect when identityUpdate passes a new tag", () => {
const game = new GameServer("g-3", mockLogger, Date.now(), {
gameType: GameType.Private,
} as any);
const client = seedClient(game, "ABC");
game.rejoinClient(mkWs() as any, "pid-1", 0, {
username: "tester",
clanTag: "XYZ",
});
expect(client.clanTag).toBe("XYZ");
});
it("does not change identity if the game has already started", () => {
const game = new GameServer("g-4", mockLogger, Date.now(), {
gameType: GameType.Private,
} as any);
const client = seedClient(game, "ABC");
(game as any)._hasStarted = true;
game.rejoinClient(mkWs() as any, "pid-1", 0, {
username: "renamed",
clanTag: "XYZ",
});
expect(client.clanTag).toBe("ABC");
expect(client.username).toBe("tester");
});
});
+141
View File
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/server/ServerEnv", () => ({
ServerEnv: {
jwtIssuer: () => "http://auth.test",
apiKey: () => "test-key",
},
}));
vi.mock("../../src/server/Logger", () => ({
logger: {
child: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
},
}));
import {
_clearClanExistsCacheForTest,
clanExistsByTag,
} from "../../src/server/jwt";
const jsonResponse = (status: number, body: unknown = "") => ({
status,
text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
});
beforeEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
_clearClanExistsCacheForTest();
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("clanExistsByTag", () => {
it("returns true on HTTP 200", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(jsonResponse(200))),
);
await expect(clanExistsByTag("ABC")).resolves.toBe(true);
});
it("returns false on HTTP 404", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(jsonResponse(404))),
);
await expect(clanExistsByTag("XYZ")).resolves.toBe(false);
});
it("returns null and fails open on unexpected status (5xx)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(jsonResponse(503))),
);
await expect(clanExistsByTag("ABC")).resolves.toBeNull();
});
it("returns null and fails open on rate-limit (429)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(jsonResponse(429))),
);
await expect(clanExistsByTag("ABC")).resolves.toBeNull();
});
it("returns null on transport error", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("offline"))),
);
await expect(clanExistsByTag("ABC")).resolves.toBeNull();
});
it("caches results across calls within TTL", async () => {
const fetchSpy = vi.fn(() => Promise.resolve(jsonResponse(200)));
vi.stubGlobal("fetch", fetchSpy);
await clanExistsByTag("ABC");
await clanExistsByTag("ABC");
await clanExistsByTag("ABC");
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("does not cache fail-open (null) results so transient outages recover", async () => {
const fetchSpy = vi
.fn()
.mockResolvedValueOnce(jsonResponse(503))
.mockResolvedValueOnce(jsonResponse(200));
vi.stubGlobal("fetch", fetchSpy);
await expect(clanExistsByTag("ABC")).resolves.toBeNull();
await expect(clanExistsByTag("ABC")).resolves.toBe(true);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it("uppercases the tag in the URL", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(jsonResponse(200)),
);
vi.stubGlobal("fetch", fetchSpy);
await clanExistsByTag("abc");
const calledUrl = fetchSpy.mock.calls[0]![0] as string;
expect(calledUrl).toContain("/public/clan/ABC/exists");
});
it("treats a body {exists:false} as false on 200 (forward-compat)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.resolve(jsonResponse(200, { exists: false }))),
);
await expect(clanExistsByTag("ABC")).resolves.toBe(false);
});
it("caches by uppercased tag (different cases hit the same entry)", async () => {
const fetchSpy = vi.fn(() => Promise.resolve(jsonResponse(200)));
vi.stubGlobal("fetch", fetchSpy);
await clanExistsByTag("abc");
await clanExistsByTag("ABC");
await clanExistsByTag("Abc");
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("sends Accept: application/json header", async () => {
const fetchSpy = vi.fn(
(_input: string | URL | Request, _init?: RequestInit) =>
Promise.resolve(jsonResponse(200)),
);
vi.stubGlobal("fetch", fetchSpy);
await clanExistsByTag("ABC");
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
expect((init.headers as Record<string, string>).Accept).toBe(
"application/json",
);
});
});