mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +00:00
refactor/simplify
This commit is contained in:
@@ -662,7 +662,8 @@
|
||||
"tag_too_short": "Clan tag must be 2-5 alphanumeric characters.",
|
||||
"tag_too_long": "Clan tag cannot exceed 5 characters.",
|
||||
"tag_invalid_chars": "Clan tag can only contain letters and numbers.",
|
||||
"tag_not_member": "Join the {tag} clan before using its tag."
|
||||
"tag_not_member": "Join the {tag} clan before using its tag.",
|
||||
"tag_check_failed": "Couldn't verify clan tag. Try again or remove it."
|
||||
},
|
||||
"host_modal": {
|
||||
"title": "Create Private Lobby",
|
||||
|
||||
+5
-21
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
ClanExistsResponseSchema,
|
||||
clanExistsApiPath,
|
||||
} from "../core/ApiSchemas";
|
||||
import { clanExistsApiPath } from "../core/ApiSchemas";
|
||||
import {
|
||||
type ClanBansResponse,
|
||||
ClanBansResponseSchema,
|
||||
@@ -36,9 +33,9 @@ export type {
|
||||
ClanInfo,
|
||||
ClanJoinRequest,
|
||||
ClanMember,
|
||||
ClanMembersResponse,
|
||||
ClanMemberStats,
|
||||
ClanMemberWL,
|
||||
ClanMembersResponse,
|
||||
ClanRequestsResponse,
|
||||
} from "../core/ClanApiSchemas";
|
||||
|
||||
@@ -131,28 +128,15 @@ 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, timeouts, or transport errors so callers
|
||||
// can fail open.
|
||||
// Public existence probe (no auth). null = inconclusive (timeout / error /
|
||||
// unexpected status); the caller decides how to handle it.
|
||||
export async function fetchClanExists(tag: string): Promise<boolean | null> {
|
||||
try {
|
||||
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 === 200) return true;
|
||||
if (res.status === 404) return false;
|
||||
return null;
|
||||
} catch {
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from "../core/validations/username";
|
||||
import { IdentityReadyController } from "./identity/IdentityReadyController";
|
||||
import {
|
||||
awaitIdentityReady,
|
||||
getClanTagForSubmit,
|
||||
initIdentityFromStorage,
|
||||
revalidateIdentityTranslations,
|
||||
@@ -94,9 +93,9 @@ export class ClanTagInput extends LitElement {
|
||||
|
||||
private translatedError(raw: string): string {
|
||||
if (!raw) return "";
|
||||
// Ownership errors are stored as i18n keys (with optional tag param);
|
||||
// format errors are already-translated strings from validateClanTag.
|
||||
if (raw === "username.tag_not_member") {
|
||||
// Ownership errors are stored as i18n keys; format errors are already
|
||||
// translated strings from validateClanTag.
|
||||
if (raw.startsWith("username.")) {
|
||||
return translateText(raw, { tag: this.identity.state.clanTag.value });
|
||||
}
|
||||
return raw;
|
||||
@@ -122,12 +121,6 @@ export class ClanTagInput extends LitElement {
|
||||
input.value = sanitized;
|
||||
}
|
||||
|
||||
// Resolves once any in-flight async ownership check settles. Returns
|
||||
// immediately when nothing is in flight.
|
||||
public async awaitValidation(): Promise<void> {
|
||||
await awaitIdentityReady();
|
||||
}
|
||||
|
||||
public showValidationFeedback() {
|
||||
const message =
|
||||
this.translatedError(this.identity.state.clanTag.error) ||
|
||||
|
||||
+3
-4
@@ -812,10 +812,9 @@ class Client {
|
||||
private async handleJoinLobby(event: CustomEvent<JoinLobbyEvent>) {
|
||||
const lobby = event.detail;
|
||||
this.mostRecentJoinEvent = event.timeStamp;
|
||||
// Final identity gate. Callers (play buttons, modals) already disable
|
||||
// themselves until identity is ready, so this is a defense-in-depth check
|
||||
// for stray dispatches — bail silently rather than show a toast since
|
||||
// the inputs already render their own inline errors.
|
||||
// Wait for any in-flight clan-tag check, then gate: buttons disable
|
||||
// themselves until ready, and this also covers programmatic/deep-link
|
||||
// joins. Bail silently — the inputs render their own inline errors.
|
||||
const ready = await awaitIdentityReady();
|
||||
if (!ready) {
|
||||
console.warn("join-lobby blocked: identity not ready");
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import {
|
||||
awaitIdentityReady,
|
||||
getIdentityState,
|
||||
subscribeIdentity,
|
||||
type IdentityState,
|
||||
} from "./IdentityStore";
|
||||
|
||||
// Subscribes a Lit host to the identity store and triggers a re-render
|
||||
// whenever the ready/validating state changes. Lets play buttons bind
|
||||
// `?disabled=${!identity.ready}` without bespoke wiring per consumer.
|
||||
// Subscribes a Lit host to the identity store and re-renders it whenever the
|
||||
// ready state changes, so play buttons can bind `?disabled=${!identity.ready}`.
|
||||
export class IdentityReadyController implements ReactiveController {
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private snapshot: IdentityState;
|
||||
@@ -41,8 +39,4 @@ export class IdentityReadyController implements ReactiveController {
|
||||
get state(): IdentityState {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
async awaitReady(): Promise<boolean> {
|
||||
return awaitIdentityReady();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { sanitizeClanTag } from "../../core/Util";
|
||||
import {
|
||||
MAX_CLAN_TAG_LENGTH,
|
||||
MIN_CLAN_TAG_LENGTH,
|
||||
validateClanTag,
|
||||
validateUsername,
|
||||
} from "../../core/validations/username";
|
||||
@@ -36,18 +34,13 @@ const state: IdentityState = {
|
||||
ready: false,
|
||||
};
|
||||
|
||||
let lastInput: { username: string; clanTag: string } = {
|
||||
username: "",
|
||||
clanTag: "",
|
||||
};
|
||||
|
||||
function recomputeReady() {
|
||||
state.ready =
|
||||
state.username.valid && state.clanTag.valid && !state.clanTagChecking;
|
||||
}
|
||||
let lastInput = { username: "", clanTag: "" };
|
||||
|
||||
function emit() {
|
||||
recomputeReady();
|
||||
// Play is gated until the username is valid AND the clan tag is proven OK
|
||||
// (owned or fictional). While a check is in flight, nothing is proven yet.
|
||||
state.ready =
|
||||
state.username.valid && state.clanTag.valid && !state.clanTagChecking;
|
||||
for (const listener of listeners) listener(state);
|
||||
}
|
||||
|
||||
@@ -67,19 +60,10 @@ export function getUsernameForSubmit(): string {
|
||||
return state.username.value;
|
||||
}
|
||||
|
||||
// Mirrors the legacy ClanTagInput.getValue contract: only emit a non-null
|
||||
// value when the tag is valid AND meets length AND format. Empty / pending /
|
||||
// failed states submit as null so the server falls back to "no tag".
|
||||
export function getClanTagForSubmit(): string | null {
|
||||
// Don't submit a tag while the ownership check is in flight — callers
|
||||
// either gate on `state.ready` first or await `awaitIdentityReady()`.
|
||||
if (state.clanTagChecking) return null;
|
||||
const { value, valid } = state.clanTag;
|
||||
if (!valid) return null;
|
||||
if (value.length < MIN_CLAN_TAG_LENGTH) return null;
|
||||
if (value.length > MAX_CLAN_TAG_LENGTH) return null;
|
||||
if (!validateClanTag(value).isValid) return null;
|
||||
return value;
|
||||
return valid && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function setUsername(raw: string) {
|
||||
@@ -91,9 +75,7 @@ export function setUsername(raw: string) {
|
||||
valid: result.isValid,
|
||||
error: result.isValid ? "" : (result.error ?? ""),
|
||||
};
|
||||
if (result.isValid) {
|
||||
localStorage.setItem(USERNAME_KEY, trimmed);
|
||||
}
|
||||
if (result.isValid) localStorage.setItem(USERNAME_KEY, trimmed);
|
||||
emit();
|
||||
}
|
||||
|
||||
@@ -107,12 +89,12 @@ export function setClanTag(raw: string, options: { immediate?: boolean } = {}) {
|
||||
const tag = sanitizeClanTag(raw);
|
||||
const result = validateClanTag(tag);
|
||||
|
||||
// Cancel any pending/in-flight ownership work. checkCounter++ marks stale
|
||||
// chains; resolving the prior debounce lets awaitReady() callers unblock.
|
||||
// A new value supersedes any pending/in-flight check and unblocks
|
||||
// awaitIdentityReady() callers waiting on the prior one.
|
||||
if (clanCheckTimer !== null) clearTimeout(clanCheckTimer);
|
||||
clanCheckTimer = null;
|
||||
clanCheckCounter++;
|
||||
if (resolveDebounce) resolveDebounce();
|
||||
resolveDebounce?.();
|
||||
resolveDebounce = null;
|
||||
|
||||
state.clanTag = {
|
||||
@@ -122,8 +104,6 @@ export function setClanTag(raw: string, options: { immediate?: boolean } = {}) {
|
||||
};
|
||||
|
||||
if (!result.isValid || tag.length === 0) {
|
||||
// Nothing to ask the server about. Wipe the stored tag so a reload
|
||||
// doesn't restore a stale value that no longer matches input.
|
||||
state.clanTagChecking = false;
|
||||
localStorage.setItem(CLAN_TAG_KEY, "");
|
||||
currentCheck = Promise.resolve();
|
||||
@@ -131,15 +111,15 @@ export function setClanTag(raw: string, options: { immediate?: boolean } = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Well-formed tag: nothing is proven until the ownership check resolves.
|
||||
state.clanTagChecking = true;
|
||||
emit();
|
||||
|
||||
const generation = clanCheckCounter;
|
||||
const run = (): Promise<void> => {
|
||||
if (generation !== clanCheckCounter) return Promise.resolve();
|
||||
return runOwnershipCheck(tag, generation);
|
||||
};
|
||||
|
||||
const run = () =>
|
||||
generation === clanCheckCounter
|
||||
? runOwnershipCheck(tag, generation)
|
||||
: Promise.resolve();
|
||||
if (options.immediate) {
|
||||
currentCheck = run();
|
||||
} else {
|
||||
@@ -156,6 +136,9 @@ export function setClanTag(raw: string, options: { immediate?: boolean } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Members are always accepted. A non-member keeps a tag only if the clan is
|
||||
// fictional; a real clan they don't belong to, or anything we can't verify,
|
||||
// is rejected so play stays gated until the tag is proven.
|
||||
async function runOwnershipCheck(tag: string, generation: number) {
|
||||
const stillCurrent = () =>
|
||||
generation === clanCheckCounter && state.clanTag.value === tag;
|
||||
@@ -165,16 +148,16 @@ async function runOwnershipCheck(tag: string, generation: number) {
|
||||
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 !== false) {
|
||||
rejectTag(tag);
|
||||
return;
|
||||
}
|
||||
if (myTags.includes(tag.toUpperCase())) {
|
||||
acceptTag(tag);
|
||||
return;
|
||||
}
|
||||
acceptTag(tag);
|
||||
|
||||
const exists = await fetchClanExists(tag);
|
||||
if (!stillCurrent()) return;
|
||||
if (exists === false) acceptTag(tag);
|
||||
else if (exists === true) rejectTag(tag, "username.tag_not_member");
|
||||
else rejectTag(tag, "username.tag_check_failed");
|
||||
}
|
||||
|
||||
function acceptTag(tag: string) {
|
||||
@@ -184,19 +167,14 @@ function acceptTag(tag: string) {
|
||||
emit();
|
||||
}
|
||||
|
||||
function rejectTag(tag: string) {
|
||||
state.clanTag = {
|
||||
value: tag,
|
||||
valid: false,
|
||||
error: "username.tag_not_member",
|
||||
};
|
||||
function rejectTag(tag: string, error: string) {
|
||||
state.clanTag = { value: tag, valid: false, error };
|
||||
state.clanTagChecking = false;
|
||||
localStorage.removeItem(CLAN_TAG_KEY);
|
||||
emit();
|
||||
}
|
||||
|
||||
// Resolves once any in-flight async clan check settles. Returns the final
|
||||
// ready state so callers can branch without a second read.
|
||||
// Resolves once any in-flight clan check settles; returns the final ready state.
|
||||
export async function awaitIdentityReady(): Promise<boolean> {
|
||||
let last: Promise<void> | undefined;
|
||||
while (currentCheck !== last) {
|
||||
@@ -206,28 +184,21 @@ export async function awaitIdentityReady(): Promise<boolean> {
|
||||
return state.ready;
|
||||
}
|
||||
|
||||
// Re-runs sync validation against the last raw input so error messages get
|
||||
// re-translated when the active language changes. Does NOT re-trigger the
|
||||
// async ownership check (the cached result is still correct).
|
||||
// Re-runs sync validation against the last raw input so error strings get
|
||||
// re-translated on a language change. A confirmed ownership error (i18n key,
|
||||
// format-valid) is preserved.
|
||||
export function revalidateIdentityTranslations() {
|
||||
const trimmed = lastInput.username.trim();
|
||||
const usernameResult = validateUsername(trimmed);
|
||||
const u = validateUsername(trimmed);
|
||||
state.username = {
|
||||
value: trimmed,
|
||||
valid: usernameResult.isValid,
|
||||
error: usernameResult.isValid ? "" : (usernameResult.error ?? ""),
|
||||
valid: u.isValid,
|
||||
error: u.isValid ? "" : (u.error ?? ""),
|
||||
};
|
||||
|
||||
const tag = sanitizeClanTag(lastInput.clanTag);
|
||||
const tagResult = validateClanTag(tag);
|
||||
// Preserve any existing ownership error (it's already an i18n key); only
|
||||
// refresh the format-level error so language changes pick up new strings.
|
||||
if (!tagResult.isValid) {
|
||||
state.clanTag = {
|
||||
value: tag,
|
||||
valid: false,
|
||||
error: tagResult.error ?? "",
|
||||
};
|
||||
const t = validateClanTag(tag);
|
||||
if (!t.isValid) {
|
||||
state.clanTag = { value: tag, valid: false, error: t.error ?? "" };
|
||||
}
|
||||
emit();
|
||||
}
|
||||
@@ -236,18 +207,11 @@ let initialized = false;
|
||||
export function initIdentityFromStorage() {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
const storedUsername = localStorage.getItem(USERNAME_KEY) ?? "";
|
||||
setUsername(storedUsername);
|
||||
const storedClanTag = localStorage.getItem(CLAN_TAG_KEY) ?? "";
|
||||
if (storedClanTag.length > 0) {
|
||||
setClanTag(storedClanTag, { immediate: true });
|
||||
} else {
|
||||
setClanTag("", { immediate: true });
|
||||
}
|
||||
setUsername(localStorage.getItem(USERNAME_KEY) ?? "");
|
||||
setClanTag(localStorage.getItem(CLAN_TAG_KEY) ?? "", { immediate: true });
|
||||
}
|
||||
|
||||
// Test-only reset; not part of the public surface but exported so unit tests
|
||||
// can wipe singleton state between cases.
|
||||
// Test-only reset for the singleton module state.
|
||||
export function __resetIdentityStoreForTests() {
|
||||
initialized = false;
|
||||
listeners.clear();
|
||||
|
||||
+2
-15
@@ -21,25 +21,12 @@ 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.
|
||||
// Existence-probe path (200 = exists, 404 = not); uppercased to match the
|
||||
// canonical tag form. Shared so the client probe and server enforcement agree.
|
||||
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
|
||||
|
||||
+13
-115
@@ -1,129 +1,41 @@
|
||||
import {
|
||||
ClanExistsResponseSchema,
|
||||
clanExistsApiPath,
|
||||
type UserMeResponse,
|
||||
} from "../core/ApiSchemas";
|
||||
|
||||
// Clan-existence probe used by the join-time ownership check.
|
||||
//
|
||||
// Only positive results are cached: a fictional tag (clan does not exist
|
||||
// upstream) could legitimately become real moments later, and we don't want a
|
||||
// stale "false" to leak a tag past the ownership check until TTL expiry.
|
||||
// Positive results are safe to cache because a real clan stays real for the
|
||||
// life of any reasonable cache entry, and a cache hit returns "exists=true",
|
||||
// which only ever causes the tag to be dropped (fail-closed).
|
||||
import { clanExistsApiPath, type UserMeResponse } from "../core/ApiSchemas";
|
||||
|
||||
export const CLAN_EXISTS_FETCH_TIMEOUT_MS = 3000;
|
||||
export const CLAN_EXISTS_CACHE_TTL_MS = 60_000;
|
||||
export const CLAN_EXISTS_CACHE_MAX_ENTRIES = 1024;
|
||||
|
||||
interface CacheEntry {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// Insertion-ordered Map gives us a trivial FIFO/LRU: re-set on hit to bump
|
||||
// freshness, evict from the oldest end when the bound is reached.
|
||||
const positiveCache = new Map<string, CacheEntry>();
|
||||
|
||||
interface ProbeDeps {
|
||||
/** Base URL of the upstream auth API (issuer). */
|
||||
baseUrl: string;
|
||||
/** Injected so tests can stub network behavior. */
|
||||
fetcher?: typeof fetch;
|
||||
/** Injected so tests control time. */
|
||||
now?: () => number;
|
||||
/** Logger callback for unexpected statuses / transport errors. */
|
||||
onWarn?: (event: string, ctx: Record<string, unknown>) => void;
|
||||
/** Override the shared cache (tests). */
|
||||
cache?: Map<string, CacheEntry>;
|
||||
/** Override TTL / bound (tests). */
|
||||
ttlMs?: number;
|
||||
maxEntries?: number;
|
||||
}
|
||||
|
||||
function getCachedExists(
|
||||
cache: Map<string, CacheEntry>,
|
||||
key: string,
|
||||
nowMs: number,
|
||||
): boolean | undefined {
|
||||
const entry = cache.get(key);
|
||||
if (entry === undefined) return undefined;
|
||||
if (nowMs >= entry.expiresAt) {
|
||||
cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
// Bump LRU recency.
|
||||
cache.delete(key);
|
||||
cache.set(key, entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
function setCachedExists(
|
||||
cache: Map<string, CacheEntry>,
|
||||
key: string,
|
||||
expiresAt: number,
|
||||
maxEntries: number,
|
||||
): void {
|
||||
cache.set(key, { expiresAt });
|
||||
while (cache.size > maxEntries) {
|
||||
const oldest = cache.keys().next();
|
||||
if (oldest.done) break;
|
||||
cache.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the tag matches a real clan upstream, false if it does not,
|
||||
* and null when the result is inconclusive (transport error, timeout, or
|
||||
* unexpected status). Callers should treat null as fail-closed (drop the tag).
|
||||
* unexpected status). Callers treat null as fail-closed (drop the tag).
|
||||
*/
|
||||
export async function clanExistsByTag(
|
||||
tag: string,
|
||||
deps: ProbeDeps,
|
||||
): Promise<boolean | null> {
|
||||
const cache = deps.cache ?? positiveCache;
|
||||
const now = deps.now ?? Date.now;
|
||||
const fetcher = deps.fetcher ?? fetch;
|
||||
const ttlMs = deps.ttlMs ?? CLAN_EXISTS_CACHE_TTL_MS;
|
||||
const maxEntries = deps.maxEntries ?? CLAN_EXISTS_CACHE_MAX_ENTRIES;
|
||||
|
||||
const cacheKey = tag.toUpperCase();
|
||||
const cached = getCachedExists(cache, cacheKey, now());
|
||||
if (cached === true) return true;
|
||||
|
||||
try {
|
||||
const url = `${deps.baseUrl}${clanExistsApiPath(tag)}`;
|
||||
const response = await fetcher(url, {
|
||||
const response = await fetcher(`${deps.baseUrl}${clanExistsApiPath(tag)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: AbortSignal.timeout(CLAN_EXISTS_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (response.status === 200) {
|
||||
// Upstream currently has no body; tolerate {exists:false} for forward-compat.
|
||||
try {
|
||||
const text = await response.text();
|
||||
if (text.length > 0) {
|
||||
const parsed = ClanExistsResponseSchema.safeParse(JSON.parse(text));
|
||||
if (parsed.success && parsed.data?.exists === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Forward-compat parsing only; ignore failures.
|
||||
}
|
||||
setCachedExists(cache, cacheKey, now() + ttlMs, maxEntries);
|
||||
return true;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return false;
|
||||
}
|
||||
if (response.status === 200) return true;
|
||||
if (response.status === 404) return false;
|
||||
deps.onWarn?.("clanExistsByTag: unexpected status, failing closed", {
|
||||
tag: cacheKey,
|
||||
tag: tag.toUpperCase(),
|
||||
status: response.status,
|
||||
});
|
||||
return null;
|
||||
} catch (e) {
|
||||
deps.onWarn?.("clanExistsByTag: fetch failed, failing closed", {
|
||||
tag: cacheKey,
|
||||
tag: tag.toUpperCase(),
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
return null;
|
||||
@@ -133,13 +45,11 @@ export async function clanExistsByTag(
|
||||
/**
|
||||
* Decide whether a player may wear the given (already-censored) clan tag.
|
||||
*
|
||||
* - Members of the tag's clan always pass through unchanged.
|
||||
* - Non-members keep the tag only when the upstream API confirms the clan
|
||||
* does not exist (fictional tag).
|
||||
* - Inconclusive existence results drop the tag (fail-closed).
|
||||
*
|
||||
* @returns the resolved tag (the original or null) along with a `dropped` flag
|
||||
* so callers can log impersonation attempts.
|
||||
* - Members of the tag's clan pass through unchanged.
|
||||
* - Non-members keep the tag only when the API confirms no such clan exists
|
||||
* (a fictional tag).
|
||||
* - A real clan the player isn't in, or an inconclusive check, drops the tag
|
||||
* (fail-closed) — `reason` lets callers log the impersonation attempt.
|
||||
*/
|
||||
export async function resolveClanTag(
|
||||
censoredTag: string | null,
|
||||
@@ -162,22 +72,10 @@ export async function resolveClanTag(
|
||||
}
|
||||
|
||||
const exists = await existsChecker(censoredTag);
|
||||
if (exists === false) {
|
||||
return { tag: censoredTag, dropped: false };
|
||||
}
|
||||
if (exists === false) return { tag: censoredTag, dropped: false };
|
||||
return {
|
||||
tag: null,
|
||||
dropped: true,
|
||||
reason: exists === true ? "exists" : "inconclusive",
|
||||
};
|
||||
}
|
||||
|
||||
/** Exposed for tests so each spec starts with a clean cache. */
|
||||
export function __resetClanExistsCacheForTests(): void {
|
||||
positiveCache.clear();
|
||||
}
|
||||
|
||||
/** Exposed for tests/observability. */
|
||||
export function __peekClanExistsCacheSize(): number {
|
||||
return positiveCache.size;
|
||||
}
|
||||
|
||||
@@ -39,11 +39,21 @@ import {
|
||||
} from "../../src/client/identity/IdentityStore";
|
||||
|
||||
const flushPromises = async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
const anonMe = {
|
||||
user: {},
|
||||
player: {
|
||||
publicId: "p1",
|
||||
adfree: false,
|
||||
achievements: { singleplayerMap: [] },
|
||||
friends: [],
|
||||
subscription: null,
|
||||
clans: [],
|
||||
},
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(getUserMe).mockReset();
|
||||
@@ -59,18 +69,8 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("IdentityStore clan-tag 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);
|
||||
it("rejects with tag_not_member when not a member and the clan exists", async () => {
|
||||
vi.mocked(getUserMe).mockResolvedValue(anonMe);
|
||||
vi.mocked(fetchClanExists).mockResolvedValue(true);
|
||||
|
||||
setClanTag("ABC");
|
||||
@@ -83,18 +83,8 @@ describe("IdentityStore clan-tag ownership check", () => {
|
||||
expect(state.clanTag.error).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);
|
||||
it("clears any stored clanTag on an ownership conflict", async () => {
|
||||
vi.mocked(getUserMe).mockResolvedValue(anonMe);
|
||||
vi.mocked(fetchClanExists).mockResolvedValue(true);
|
||||
localStorage.setItem("clanTag", "ABC");
|
||||
|
||||
@@ -106,7 +96,7 @@ describe("IdentityStore clan-tag ownership check", () => {
|
||||
expect(localStorage.getItem("clanTag")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the tag when the clan does not exist (fictional)", async () => {
|
||||
it("accepts a fictional tag (clan does not exist)", async () => {
|
||||
vi.mocked(getUserMe).mockResolvedValue(false);
|
||||
vi.mocked(fetchClanExists).mockResolvedValue(false);
|
||||
|
||||
@@ -119,7 +109,22 @@ describe("IdentityStore clan-tag ownership check", () => {
|
||||
expect(localStorage.getItem("clanTag")).toBe("FIC");
|
||||
});
|
||||
|
||||
it("fails closed: rejects the tag when existence check is inconclusive", async () => {
|
||||
it("accepts a member's tag without probing existence", async () => {
|
||||
vi.mocked(getUserMe).mockResolvedValue({
|
||||
...anonMe,
|
||||
player: { ...anonMe.player, clans: [{ tag: "ABC" }] },
|
||||
});
|
||||
|
||||
setClanTag("ABC");
|
||||
vi.advanceTimersByTime(401);
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
expect(getIdentityState().clanTag.valid).toBe(true);
|
||||
expect(fetchClanExists).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects with tag_check_failed when existence is inconclusive", async () => {
|
||||
vi.mocked(getUserMe).mockResolvedValue(false);
|
||||
vi.mocked(fetchClanExists).mockResolvedValue(null);
|
||||
|
||||
@@ -128,7 +133,10 @@ describe("IdentityStore clan-tag ownership check", () => {
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
expect(getIdentityState().clanTag.valid).toBe(false);
|
||||
// Can't prove ownership -> stays gated, with a message telling the user.
|
||||
const state = getIdentityState();
|
||||
expect(state.clanTag.valid).toBe(false);
|
||||
expect(state.clanTag.error).toBe("username.tag_check_failed");
|
||||
});
|
||||
|
||||
it("discards stale async results when the tag has changed", async () => {
|
||||
@@ -146,18 +154,16 @@ describe("IdentityStore clan-tag ownership check", () => {
|
||||
vi.advanceTimersByTime(401);
|
||||
await flushPromises();
|
||||
|
||||
// User switches to a different tag before the first response lands.
|
||||
// Switch tags before the first probe lands.
|
||||
setClanTag("BBB");
|
||||
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.
|
||||
// Stale "AAA exists" must not clobber BBB.
|
||||
resolveFirst(true);
|
||||
await flushPromises();
|
||||
expect(getIdentityState().clanTag.error).toBe("");
|
||||
|
||||
// Second response says BBB doesn't exist → fictional, accept.
|
||||
resolveSecond(false);
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
@@ -166,13 +172,12 @@ describe("IdentityStore clan-tag ownership check", () => {
|
||||
expect(localStorage.getItem("clanTag")).toBe("BBB");
|
||||
});
|
||||
|
||||
it("flips ready false while a check is in flight, true on success", async () => {
|
||||
it("gates play while a check is in flight, then clears it", async () => {
|
||||
vi.mocked(getUserMe).mockResolvedValue(false);
|
||||
vi.mocked(fetchClanExists).mockResolvedValue(false);
|
||||
|
||||
setClanTag("ABC");
|
||||
// Pre-debounce: checking flag already set so play buttons disable
|
||||
// immediately, not after the debounce.
|
||||
// Pre-debounce: checking is already set so buttons disable immediately.
|
||||
expect(getIdentityState().clanTagChecking).toBe(true);
|
||||
expect(getIdentityState().ready).toBe(false);
|
||||
|
||||
@@ -183,18 +188,17 @@ describe("IdentityStore clan-tag ownership check", () => {
|
||||
expect(getIdentityState().clanTag.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("getClanTagForSubmit returns null while empty/short/pending; tag once accepted", async () => {
|
||||
it("getClanTagForSubmit: null while empty/short/checking; tag once accepted", async () => {
|
||||
vi.mocked(getUserMe).mockResolvedValue(false);
|
||||
vi.mocked(fetchClanExists).mockResolvedValue(false);
|
||||
|
||||
setClanTag("");
|
||||
expect(getClanTagForSubmit()).toBeNull();
|
||||
|
||||
setClanTag("A");
|
||||
setClanTag("A"); // too short -> format invalid
|
||||
expect(getClanTagForSubmit()).toBeNull();
|
||||
|
||||
setClanTag("ABC");
|
||||
// Ownership check hasn't resolved yet → not submittable.
|
||||
expect(getIdentityState().clanTagChecking).toBe(true);
|
||||
expect(getClanTagForSubmit()).toBeNull();
|
||||
|
||||
|
||||
@@ -121,16 +121,6 @@ describe("fetchClanExists", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clanExistsApiPath } from "../../src/core/ApiSchemas";
|
||||
|
||||
describe("clanExistsApiPath", () => {
|
||||
it("uppercases and URL-encodes the tag", () => {
|
||||
expect(clanExistsApiPath("abc")).toBe("/public/clan/ABC/exists");
|
||||
expect(clanExistsApiPath("a/b")).toBe("/public/clan/A%2FB/exists");
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { UserMeResponse } from "../../src/core/ApiSchemas";
|
||||
import {
|
||||
clanExistsByTag,
|
||||
resolveClanTag,
|
||||
} from "../../src/server/ClanTagOwnership";
|
||||
|
||||
const okResponse = (status: number, body = ""): Response =>
|
||||
({
|
||||
status,
|
||||
text: async () => body,
|
||||
}) as unknown as Response;
|
||||
const okResponse = (status: number): Response =>
|
||||
({ status }) as unknown as Response;
|
||||
|
||||
const userWithClans = (tags: string[]): UserMeResponse =>
|
||||
({
|
||||
@@ -32,145 +29,51 @@ const userWithClans = (tags: string[]): UserMeResponse =>
|
||||
}) as UserMeResponse;
|
||||
|
||||
describe("clanExistsByTag", () => {
|
||||
let cache: Map<string, { expiresAt: number }>;
|
||||
let now: number;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new Map();
|
||||
now = 1_000_000;
|
||||
const deps = (fetcher: () => Promise<Response>) => ({
|
||||
baseUrl: "https://auth.example",
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
it("returns true on HTTP 200", async () => {
|
||||
const fetcher = vi.fn(async () => okResponse(200));
|
||||
const result = await clanExistsByTag("ABC", {
|
||||
baseUrl: "https://auth.example",
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
cache,
|
||||
now: () => now,
|
||||
});
|
||||
const result = await clanExistsByTag(
|
||||
"ABC",
|
||||
deps(async () => okResponse(200)),
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false on HTTP 404 without caching it", async () => {
|
||||
const fetcher = vi.fn(async () => okResponse(404));
|
||||
const result = await clanExistsByTag("XYZ", {
|
||||
baseUrl: "https://auth.example",
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
cache,
|
||||
now: () => now,
|
||||
});
|
||||
it("returns false on HTTP 404", async () => {
|
||||
const result = await clanExistsByTag(
|
||||
"XYZ",
|
||||
deps(async () => okResponse(404)),
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
// Negative results must not poison the cache — a clan can be created
|
||||
// moments after a 404 and a stale "false" would briefly let non-members
|
||||
// wear the tag.
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns null on unexpected status (fail-closed) and does not cache", async () => {
|
||||
const fetcher = vi.fn(async () => okResponse(503));
|
||||
const result = await clanExistsByTag("ABC", {
|
||||
baseUrl: "https://auth.example",
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
cache,
|
||||
now: () => now,
|
||||
});
|
||||
it("returns null on unexpected status (fail-closed)", async () => {
|
||||
const result = await clanExistsByTag(
|
||||
"ABC",
|
||||
deps(async () => okResponse(503)),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns null on transport error (fail-closed)", async () => {
|
||||
const fetcher = vi.fn(async () => {
|
||||
throw new Error("offline");
|
||||
});
|
||||
const result = await clanExistsByTag("ABC", {
|
||||
baseUrl: "https://auth.example",
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
cache,
|
||||
now: () => now,
|
||||
});
|
||||
const result = await clanExistsByTag(
|
||||
"ABC",
|
||||
deps(async () => {
|
||||
throw new Error("offline");
|
||||
}),
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("uppercases the tag in the request URL", async () => {
|
||||
const fetcher = vi.fn(async () => okResponse(200));
|
||||
await clanExistsByTag("abc", {
|
||||
baseUrl: "https://auth.example",
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
cache,
|
||||
now: () => now,
|
||||
});
|
||||
await clanExistsByTag("abc", deps(fetcher));
|
||||
const calledUrl = (fetcher.mock.calls[0] as unknown[])[0] as string;
|
||||
expect(calledUrl).toContain("/public/clan/ABC/exists");
|
||||
});
|
||||
|
||||
it("serves positive results from cache without re-fetching", async () => {
|
||||
const fetcher = vi.fn(async () => okResponse(200));
|
||||
await clanExistsByTag("ABC", {
|
||||
baseUrl: "https://auth.example",
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
cache,
|
||||
now: () => now,
|
||||
});
|
||||
await clanExistsByTag("ABC", {
|
||||
baseUrl: "https://auth.example",
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
cache,
|
||||
now: () => now,
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-fetches positive entries after TTL expiry", async () => {
|
||||
const fetcher = vi.fn(async () => okResponse(200));
|
||||
await clanExistsByTag("ABC", {
|
||||
baseUrl: "https://auth.example",
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
cache,
|
||||
now: () => now,
|
||||
ttlMs: 1000,
|
||||
});
|
||||
now += 2000;
|
||||
await clanExistsByTag("ABC", {
|
||||
baseUrl: "https://auth.example",
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
cache,
|
||||
now: () => now,
|
||||
ttlMs: 1000,
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("evicts the oldest entry when maxEntries is exceeded", async () => {
|
||||
const fetcher = vi.fn(async () => okResponse(200));
|
||||
const deps = {
|
||||
baseUrl: "https://auth.example",
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
cache,
|
||||
now: () => now,
|
||||
maxEntries: 2,
|
||||
};
|
||||
await clanExistsByTag("A", deps);
|
||||
await clanExistsByTag("B", deps);
|
||||
await clanExistsByTag("C", deps);
|
||||
expect(cache.size).toBe(2);
|
||||
expect(cache.has("A")).toBe(false);
|
||||
expect(cache.has("B")).toBe(true);
|
||||
expect(cache.has("C")).toBe(true);
|
||||
});
|
||||
|
||||
it("treats body {exists:false} as false on 200 without caching", async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
okResponse(200, JSON.stringify({ exists: false })),
|
||||
);
|
||||
const result = await clanExistsByTag("ABC", {
|
||||
baseUrl: "https://auth.example",
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
cache,
|
||||
now: () => now,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveClanTag", () => {
|
||||
|
||||
Reference in New Issue
Block a user