diff --git a/resources/lang/en.json b/resources/lang/en.json index 74eb025cc..46e49b848 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -682,8 +682,7 @@ "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. Click this message to join it.", - "tag_check_failed": "Couldn't verify clan tag. Try again or remove it." + "tag_not_member": "Join the {tag} clan before using its tag. Click this message to join it." }, "host_modal": { "title": "Create Private Lobby", diff --git a/src/client/ClanApi.ts b/src/client/ClanApi.ts index 617d43f78..f38db93b9 100644 --- a/src/client/ClanApi.ts +++ b/src/client/ClanApi.ts @@ -162,9 +162,11 @@ export async function checkClanTagOwnership( } const exists = await fetchClanExists(tag); - if (exists === false) return { tag, error: null }; if (exists === true) return { tag: null, error: "username.tag_not_member" }; - return { tag: null, error: "username.tag_check_failed" }; + // Tag doesn't exist (fictional) or the check was inconclusive (API + // unavailable, e.g. during development) — fail open and keep the tag; + // the server re-checks authoritatively. + return { tag, error: null }; } export type ClanMemberSort = diff --git a/src/server/Privilege.ts b/src/server/Privilege.ts index 2605630dd..c31da062e 100644 --- a/src/server/Privilege.ts +++ b/src/server/Privilege.ts @@ -157,24 +157,23 @@ export type ClanTagResolution = { }; /** - * The clan-tag ownership rule, shared by every PrivilegeChecker: + * The clan-tag ownership rule: * - member of the clan -> keep the tag * - not a member, tag not reserved -> fictional tag, keep it * - otherwise -> drop it (impersonation) - * `reservedTags` is every registered tag (uppercase); null means the reserved - * list is unavailable (cosmetics infra still loading), in which case an - * unverifiable tag counts as reserved and is dropped fail-closed. + * `reservedTags` is every registered tag (uppercase). */ function decideClanTag( censoredTag: string | null, ownedClanTags: string[], - reservedTags: Set | null, + reservedTags: Set, ): ClanTagResolution { if (censoredTag === null) return { tag: null, dropped: false }; const tag = censoredTag.toUpperCase(); const isMember = ownedClanTags.some((t) => t.toUpperCase() === tag); - const isReserved = reservedTags === null || reservedTags.has(tag); - if (isMember || !isReserved) return { tag: censoredTag, dropped: false }; + if (isMember || !reservedTags.has(tag)) { + return { tag: censoredTag, dropped: false }; + } return { tag: null, dropped: true }; } @@ -372,13 +371,13 @@ export class FailOpenPrivilegeChecker implements PrivilegeChecker { return censorWithMatcher(username, clanTag, defaultMatcher); } - // No reserved-tag list while cosmetics infra is unavailable (null), so a - // non-member's tag is treated as reserved and dropped fail-closed to block - // impersonation. Members are still known from their own tag list. + // No reserved-tag list while cosmetics infra is unavailable (e.g. during + // development), so ownership can't be verified. Fail open and keep the tag + // rather than blocking everyone whenever the API service is down. resolveClanTag( censoredTag: string | null, ownedClanTags: string[], ): ClanTagResolution { - return decideClanTag(censoredTag, ownedClanTags, null); + return { tag: censoredTag, dropped: false }; } } diff --git a/tests/Privilege.test.ts b/tests/Privilege.test.ts index 9b272b3bc..5fc8e753d 100644 --- a/tests/Privilege.test.ts +++ b/tests/Privilege.test.ts @@ -575,13 +575,13 @@ describe("FailOpenPrivilegeChecker#resolveClanTag", () => { expect(result).toEqual({ tag: "ABC", dropped: false }); }); - it("drops a non-member's tag fail-closed (no reserved set while infra is down)", () => { + it("keeps a non-member's tag fail-open (no reserved set while infra is down)", () => { const result = checker.resolveClanTag("ABC", ["other"]); - expect(result).toEqual({ tag: null, dropped: true }); + expect(result).toEqual({ tag: "ABC", dropped: false }); }); - it("drops an anonymous user's tag fail-closed", () => { + it("keeps an anonymous user's tag fail-open", () => { const result = checker.resolveClanTag("ABC", []); - expect(result.dropped).toBe(true); + expect(result).toEqual({ tag: "ABC", dropped: false }); }); }); diff --git a/tests/client/clan/ClanApiQueries.test.ts b/tests/client/clan/ClanApiQueries.test.ts index 3a00d2714..0254db1df 100644 --- a/tests/client/clan/ClanApiQueries.test.ts +++ b/tests/client/clan/ClanApiQueries.test.ts @@ -152,15 +152,15 @@ describe("checkClanTagOwnership", () => { }); }); - it("rejects on an inconclusive existence check", async () => { + it("fails open on an inconclusive existence check (API unavailable)", async () => { vi.mocked(getUserMe).mockResolvedValue(false); vi.stubGlobal( "fetch", vi.fn(() => Promise.resolve(status(503))), ); await expect(checkClanTagOwnership("ABC")).resolves.toEqual({ - tag: null, - error: "username.tag_check_failed", + tag: "ABC", + error: null, }); }); });