feat(client): verified-name toggle (play under your account name) (#4648)

## Summary

The client half of the verified-name plan: subscribers with a claimed
bare name can opt in to play under it, and the game renders a
**server-validated** blue verified check next to their name.

### Verified toggle (username row)
- Blue check-circle badge + "Verified" label act as a toggle button in
the play username row, shown to all users (hidden on CrazyGames via
`no-crazygames`); both turn blue when active and the input locks to the
bare account name — `getUsername()` feeds every join path.
- Non-subscribers (logged out, `unclaimed`, lapsed `claimed`) get a
subscribe-first dialog whose **View store** routes to
`#modal=store&tab=subscriptions`. Entitled players without a usable name
(never set, or `TEMPORARY####`) are routed to the account modal instead.
- The opt-in persists in localStorage but never auto-enables while
ineligible; unchecking restores the saved free-form name. Anonymity
stays a first-class option.
- After a successful username save the page reloads so every consumer
restarts from a fresh `/users/@me`.

### In-game badge (GL name pass)
- New `verified` boolean on `PlayerCosmeticRefsSchema` (client claim)
and `PlayerCosmeticsSchema` (resolved). `getPlayerCosmeticsRefs()` sets
it from the toggle state, covering both the multiplayer join and
locally-resolved singleplayer paths.
- **Server-validated at join, today**: the Worker already fetches
`/users/@me` with the client's token on every authenticated join
(flares/friends/clans), and that response carries the account username
since #4644 — so `verifiedBadgeAllowed` keeps the claim only when the
bare-name status is `premium`/`indefinite` AND the join name exactly
matches the account's resolved display name. Zero extra requests, no
token-claim staleness. Mismatches strip the badge rather than rejecting
the join; a pre-start rejoin identity change also drops it (that path
skips join-time validation). Anonymous persistent-ID joins exist only in
Dev and keep the claim for local testing.
- Rendering: 10th instanced slot in the name pass's `StatusIconProgram`,
anchored just right of the name text (`nameHalfWidth` was already in the
player data texture), slightly below the name line's center. The badge
art is a new cell (index 11) in `status-atlas.png`; the flag rides the
free `pd8.y` column. Anonymized viewers never see it (cosmetics are
already stripped for hidden players).

## Test plan

- Full suite passes (2,047 + 173), including new tests: cosmetics schema
`verified` (optional/boolean-only), `Privilege.isAllowed` pass-through,
and `verifiedBadgeAllowed` (exact match, case rejection, unentitled
statuses, missing name).
- Headless real-app verification of every toggle state (dialogs,
persistence, silent drops, store-tab routing, save→reload) with stubbed
API routes.
- Drove a real singleplayer game headless (WebGL via ANGLE Metal): the
blue check renders to the right of "Bob", scaled and tucked to the name;
bot/nation names show no badge. Screenshot-verified.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-20 08:28:21 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent d9976babbd
commit ad8d7e995a
20 changed files with 508 additions and 80 deletions
+2 -1
View File
@@ -15,6 +15,7 @@
"nukeRed": 7,
"nukeWhite": 8,
"allianceFaded": 9,
"doomsdayClock": 10
"doomsdayClock": 10,
"verified": 11
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 164 KiB

+5 -2
View File
@@ -67,7 +67,6 @@
"tab_stats": "Stats",
"title": "Account",
"unclaimed_rewards": "Unclaimed Rewards",
"username_changed": "Username changed to {name}.",
"username_confirm_abandon": "You will permanently give up your reserved name \"{name}\".",
"username_confirm_body": "Your new name will be locked for 30 days.",
"username_confirm_button": "Change name",
@@ -1623,7 +1622,11 @@
"tag_too_long": "Clan tag cannot exceed 5 characters.",
"tag_too_short": "Clan tag must be 2-5 alphanumeric characters.",
"too_long": "Username must not exceed {max} characters.",
"too_short": "Username must be at least {min} characters long."
"too_short": "Username must be at least {min} characters long.",
"verified_heading": "Verified name",
"verified_sub_required": "You need an active subscription to play under your verified name.",
"verified_sub_required_confirm": "View store",
"verified_toggle": "Verified"
},
"win_modal": {
"died": "You died",
+1 -4
View File
@@ -465,10 +465,7 @@ export class AccountModal extends BaseModal {
private renderUsernamePanel(): TemplateResult | "" {
const player = this.userMeResponse?.player;
if (!player || player.usernameStatus === undefined) return "";
return html`<username-panel
.player=${player}
@username-changed=${() => this.requestUpdate()}
></username-panel>`;
return html`<username-panel .player=${player}></username-panel>`;
}
private renderRewardsPanel(): TemplateResult | "" {
+6
View File
@@ -30,6 +30,7 @@ import {
purchaseWithCurrency,
} from "./Api";
import { showInGameAlert, showInGameConfirm } from "./InGameModal";
import { isPlayingVerified } from "./UsernameInput";
import { translateText } from "./Utils";
export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute
@@ -759,6 +760,7 @@ export async function getPlayerCosmeticsRefs(): Promise<PlayerCosmeticRefs> {
skinName,
crownName,
effects: Object.keys(effects).length > 0 ? effects : undefined,
verified: isPlayingVerified() ? true : undefined,
};
}
@@ -823,6 +825,10 @@ export async function getPlayerCosmetics(): Promise<PlayerCosmetics> {
if (Object.keys(effects).length > 0) result.effects = effects;
}
if (refs.verified) {
result.verified = true;
}
return result;
}
+146 -3
View File
@@ -1,7 +1,8 @@
import { LitElement, html } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { translateText } from "../client/Utils";
import { ANON_ANIMALS, anonAnimalName } from "../core/AnonAnimals";
import { isTemporaryUsername, UserMeResponse } from "../core/ApiSchemas";
import { sanitizeClanTag } from "../core/Util";
import {
MAX_CLAN_TAG_LENGTH,
@@ -13,6 +14,7 @@ import {
} from "../core/validations/username";
import { checkClanTagOwnership } from "./ClanApi";
import { crazyGamesSDK } from "./CrazyGamesSDK";
import { showInGameConfirm } from "./InGameModal";
interface LangSelectorLike {
currentLang?: string;
@@ -22,11 +24,16 @@ interface LangSelectorLike {
const usernameKey: string = "username";
const clanTagKey: string = "clanTag";
const useVerifiedNameKey: string = "useVerifiedName";
@customElement("username-input")
export class UsernameInput extends LitElement {
@state() private baseUsername: string = "";
@state() private clanTag: string = "";
// Playing under the account's verified bare name (sub-only). The free-form
// name stays in baseUsername/localStorage so unchecking restores it.
@state() private verifiedActive: boolean = false;
private userMe: UserMeResponse | false | null = null;
// Clans aren't supported on CrazyGames — hide the tag input and never submit one.
private readonly onCrazyGames = crazyGamesSDK.isOnCrazyGames();
@@ -51,10 +58,88 @@ export class UsernameInput extends LitElement {
return this;
}
constructor() {
super();
// Account state for the verified-name toggle. Same document-level pattern
// as AccountModal; Main dispatches this after auth resolves and on
// CrazyGames sign-in.
document.addEventListener("userMeResponse", (event: Event) => {
this.userMe = (event as CustomEvent).detail as UserMeResponse | false;
this.applyVerifiedPreference();
});
}
// The server-resolved bare name this player may play verified under, or null
// when ineligible. Sub-only by design: `claimed` (lapsed) holders and
// TEMPORARY####-renamed players don't qualify.
private verifiedName(): string | null {
if (this.userMe === null || this.userMe === false) return null;
const player = this.userMe.player;
const status = player.usernameStatus;
if (status !== "premium" && status !== "indefinite") return null;
if (!player.username || isTemporaryUsername(player.usernameBase)) {
return null;
}
return player.username;
}
// Turn the toggle on iff the player opted in previously AND is still
// eligible; silently off otherwise (logout, lapsed sub, TEMPORARY rename).
// Never auto-enables without a stored opt-in — players who want to stay
// anonymous must be able to play under an unrelated name.
private applyVerifiedPreference() {
this.verifiedActive =
!this.onCrazyGames &&
localStorage.getItem(useVerifiedNameKey) === "true" &&
this.verifiedName() !== null;
this.requestUpdate();
this.validateAndStore();
}
private async handleVerifiedToggle() {
// verifiedActive implies eligible (applyVerifiedPreference), so this
// covers both turning off and an eligible turn-on.
if (this.verifiedActive || this.verifiedName() !== null) {
this.verifiedActive = !this.verifiedActive;
localStorage.setItem(useVerifiedNameKey, String(this.verifiedActive));
this.validateAndStore();
return;
}
// Ineligible — the toggle can't turn on.
const player = this.userMe === false ? undefined : this.userMe?.player;
const status = player?.usernameStatus;
if (status === "premium" || status === "indefinite") {
// Subscribed but no usable name yet (never set, or TEMPORARY####):
// send them to the account modal to pick one.
window.location.hash = "modal=account";
return;
}
const goStore = await showInGameConfirm(
translateText("username.verified_sub_required"),
{
heading: translateText("username.verified_heading"),
variant: "warning",
confirmText: translateText("username.verified_sub_required_confirm"),
},
);
if (goStore) {
window.location.hash = "modal=store&tab=subscriptions";
}
}
public getUsername(): string {
if (this.verifiedActive) {
const verified = this.verifiedName();
if (verified !== null) return verified;
}
return this.baseUsername.trim();
}
/** True when the player is playing under their verified account name. */
public isVerified(): boolean {
return this.verifiedActive && this.verifiedName() !== null;
}
public getClanTag(): string | null {
return this.clanTag.length >= MIN_CLAN_TAG_LENGTH &&
this.clanTag.length <= MAX_CLAN_TAG_LENGTH &&
@@ -172,13 +257,51 @@ export class UsernameInput extends LitElement {
</div>
<input
type="text"
.value=${this.baseUsername}
.value=${this.verifiedActive
? (this.verifiedName() ?? "")
: this.baseUsername}
@input=${this.handleUsernameChange}
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"
?disabled=${this.verifiedActive}
title=${this.verifiedActive
? translateText("username.verified_heading")
: ""}
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 disabled:text-blue-400 disabled:cursor-not-allowed"
/>
<button
type="button"
class="no-crazygames group flex items-center gap-1.5 shrink-0 cursor-pointer select-none"
title=${translateText("username.verified_heading")}
aria-pressed=${this.verifiedActive ? "true" : "false"}
@click=${this.handleVerifiedToggle}
>
<svg
viewBox="0 0 24 24"
class="w-5 h-5 transition-colors ${this.verifiedActive
? "text-blue-400"
: "text-white/30 group-hover:text-white/50"}"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" fill="currentColor"></circle>
<path
d="M7.5 12.5l3 3 6-6.5"
stroke="white"
stroke-width="2.2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
<span
class="hidden sm:inline text-sm font-medium transition-colors ${this
.verifiedActive
? "text-blue-400"
: "text-white/70 group-hover:text-white"}"
>${translateText("username.verified_toggle")}</span
>
</button>
</div>
${this.validationError
? html`<div
@@ -286,6 +409,19 @@ export class UsernameInput extends LitElement {
return;
}
// Playing under the verified account name: it's server-issued, so skip
// free-form validation and leave the stored free-form name untouched for
// when the toggle turns off.
if (this.verifiedActive) {
this._isValid = true;
this.validationError = "";
if (!this.onCrazyGames) {
localStorage.setItem(clanTagKey, this.getClanTag() ?? "");
}
this.emitValidity();
return;
}
const result = validateUsername(trimmedBase);
this._isValid = result.isValid;
if (result.isValid) {
@@ -319,6 +455,13 @@ export class UsernameInput extends LitElement {
}
}
// Whether the player is currently playing under their verified account name.
// For join paths that can't reach the component instance (Cosmetics refs).
export function isPlayingVerified(): boolean {
const el = document.querySelector<UsernameInput>("username-input");
return el?.isVerified() ?? false;
}
// A memorable anonymous username: "Anon" + animal (+ digit), the same handle
// format the server-side anonymisation overlay uses (anonAnimalName). Client-side
// fallback for players who never set a name — no roster here, so it draws a
+1
View File
@@ -442,6 +442,7 @@ export class WebGLFrameBuilder {
displayName: p.displayName(),
flag: flagUrl,
crown: crownUrl,
verified: p.cosmetics.verified === true,
color: p.territoryColor().toHex(),
});
}
+31 -3
View File
@@ -134,7 +134,7 @@ export class LobbyTeamView extends LitElement {
? "bg-malibu-blue/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
${displayName}
${displayName} ${this.renderVerifiedBadge(client)}
</div>`;
},
)}
@@ -196,7 +196,9 @@ export class LobbyTeamView extends LitElement {
? "current-player"
: ""}"
>
<span class="text-white">${displayName}</span>
<span class="text-white"
>${displayName} ${this.renderVerifiedBadge(client)}</span
>
${this.renderRevealToggle(client.clientID)}
${client.clientID === this.lobbyCreatorClientID
? html`<span class="host-badge"
@@ -266,7 +268,9 @@ export class LobbyTeamView extends LitElement {
? "bg-malibu-blue/20 border-sky-500/40"
: "bg-gray-700/70 border-transparent"}"
>
<span class="truncate text-white">${displayName}</span>
<span class="truncate text-white"
>${displayName} ${this.renderVerifiedBadge(p)}</span
>
${this.renderRevealToggle(p.clientID)}
${p.clientID === this.lobbyCreatorClientID
? html`<span class="ml-2 text-[11px] text-green-300"
@@ -424,4 +428,28 @@ export class LobbyTeamView extends LitElement {
createRandomName(client.username, PlayerType.Human) ?? client.username;
return formatPlayerDisplayName(anonymizedUsername, client.clanTag);
}
// Blue check for players on their server-validated account name. Withheld
// when this row's name is locally anonymized — the badge vouches for the
// exact displayed name.
private renderVerifiedBadge(client: ClientInfo) {
const anonymized =
this.userSettings.anonymousNames() && !this.isCurrentPlayer(client);
if (client.verified !== true || anonymized) return html``;
return html`<svg
viewBox="0 0 24 24"
class="inline-block w-3.5 h-3.5 align-[-2px] text-blue-400 shrink-0"
aria-label=${translateText("username.verified_heading")}
>
<circle cx="12" cy="12" r="10" fill="currentColor"></circle>
<path
d="M7.5 12.5l3 3 6-6.5"
stroke="white"
stroke-width="2.2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>`;
}
}
+7 -35
View File
@@ -115,44 +115,16 @@ export class UsernamePanel extends LitElement {
this.busy = true;
const result = await updateUsername(name);
this.busy = false;
if (result.ok) {
// The panel and AccountModal share the same player object, so updating
// it here keeps every consumer consistent; the event just triggers the
// parent re-render.
this.player.username = result.data.username;
this.player.usernameBase = result.data.base;
this.player.usernameDiscriminator = result.data.discriminator;
this.player.usernameStatus = result.data.usernameStatus;
this.player.nextUsernameChangeAt = result.data.nextUsernameChangeAt;
// A rename either kept the claim (premium/indefinite) or abandoned it
// (unclaimed) — either way no grace deadline remains.
this.player.usernameClaimExpiresAt = null;
this.draft = result.data.base;
this.error = "";
this.requestUpdate();
window.dispatchEvent(
new CustomEvent("show-message", {
detail: {
message: translateText("account_modal.username_changed", {
name: result.data.username,
}),
color: "green",
duration: 4000,
},
}),
);
this.dispatchEvent(
new CustomEvent("username-changed", {
detail: result.data,
bubbles: true,
composed: true,
}),
);
} else {
this.error = this.errorMessage(result);
// Reload so every consumer starts from a fresh /users/@me; the account
// modal reopens via #modal=account showing the new name. Keep the form
// locked (busy) while the reload happens.
window.location.reload();
return;
}
this.busy = false;
this.error = this.errorMessage(result);
}
private errorMessage(
@@ -1,9 +1,10 @@
/**
* StatusIconProgram — instanced status icons above player names.
*
* Renders up to 9 status icons per player (crown, traitor, disconnected,
* alliance, alliance request, target, embargo, nuke, doomsday-clock skull). Each
* instance reads individual float flags to decide whether to draw.
* Renders up to 10 icons per player: 9 status-row icons above the name
* (crown, traitor, disconnected, alliance, alliance request, target, embargo,
* nuke, doomsday-clock skull) plus the verified badge to the right of the
* name. Each instance reads individual float flags to decide whether to draw.
*
* Owns: shader program, uniform locations, status atlas texture.
* The shared playerDataTex is passed in but not owned/deleted.
@@ -20,7 +21,7 @@ import type { ParsedAtlas } from "./Types";
const statusAtlasUrl = assetUrl("atlases/status-atlas.png");
const MAX_STATUS_ICONS = 9;
const MAX_STATUS_ICONS = 10;
export class StatusIconProgram {
private gl: WebGL2RenderingContext;
@@ -667,9 +667,9 @@ export class NamePass {
d[off + 30] = slot.allianceFraction;
d[off + 31] = slot.allianceRemainingTicks;
// Column 8: crownLayerIdx (crown cosmetic), rest free
// Column 8: crownLayerIdx (crown cosmetic), verified badge, rest free
d[off + 32] = slot.crownLayerIdx;
d[off + 33] = 0.0;
d[off + 33] = slot.static.verified === true ? 1.0 : 0.0;
d[off + 34] = 0.0;
d[off + 35] = 0.0;
@@ -88,16 +88,19 @@ vec4 cellUV(int idx) {
}
void main() {
// Decode instance ID → playerIdx + iconSlot (0..8)
int playerIdx = gl_InstanceID / 9;
int iconSlot = gl_InstanceID - playerIdx * 9;
// Decode instance ID → playerIdx + iconSlot (0..8 status row, 9 = verified
// badge to the right of the name)
int playerIdx = gl_InstanceID / 10;
int iconSlot = gl_InstanceID - playerIdx * 10;
bool isVerifiedSlot = (iconSlot == 9);
// Read player data
vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); // srcX, srcY, srcScale, startTime
vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); // tgtX, tgtY, tgtScale, alive
vec4 pd3 = texelFetch(uPlayerData, ivec2(3, playerIdx), 0); // nameLen, troopLen, nameShade, nameHalfWidth
vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); // flagIdx, emojiIdx, smallID, [free]
vec4 pd7 = texelFetch(uPlayerData, ivec2(7, playerIdx), 0); // nukeTargetsMe, traitorRemainingTicks, allianceFraction, allianceRemainingTicks
vec4 pd8 = texelFetch(uPlayerData, ivec2(8, playerIdx), 0); // crownLayer, [free]
vec4 pd8 = texelFetch(uPlayerData, ivec2(8, playerIdx), 0); // crownLayer, verified, [free]
// A crown cosmetic skins the first-place crown (slot 0).
vCrownLayer = (iconSlot == 0 && pd8.x >= 0.0) ? int(pd8.x) : -1;
@@ -120,8 +123,10 @@ void main() {
// Read status flags into array
readStatusFlags(playerIdx);
// Early out: this icon slot is inactive
if (statusFlag[iconSlot] < 0.5) {
// Early out: this icon slot is inactive. The verified badge (slot 9) is
// driven by pd8.y, not the status-row flag array.
bool slotActive = isVerifiedSlot ? (pd8.y > 0.5) : (statusFlag[iconSlot] > 0.5);
if (!slotActive) {
gl_Position = vec4(0.0);
vUV = vec2(0.0);
vLocalUV = vec2(0.0);
@@ -168,21 +173,31 @@ void main() {
// Icon world size: matches name text height
float iconWorldSize = uFontBase * nameWorldScale * 1.1;
// Count active icons and position of this one (left-to-right)
int totalActive = 0;
for (int i = 0; i < 9; i++) {
if (statusFlag[i] > 0.5) totalActive++;
float iconX;
float iconY;
if (isVerifiedSlot) {
// Verified badge: anchored just right of the name text, sitting slightly
// below the name line's vertical center (name glyphs center on wy).
iconWorldSize = uFontBase * nameWorldScale * 0.9;
iconX = wx + pd3.w * nameWorldScale + iconWorldSize * 0.12;
iconY = wy - iconWorldSize * 0.4;
} else {
// Count active icons and position of this one (left-to-right)
int totalActive = 0;
for (int i = 0; i < 9; i++) {
if (statusFlag[i] > 0.5) totalActive++;
}
int myIndex = countBelow(iconSlot);
// Horizontal centering: spread icons evenly above the name
float gap = iconWorldSize * 0.15;
float totalWidth = float(totalActive) * iconWorldSize + float(totalActive - 1) * gap;
float startX = wx - totalWidth * 0.5;
iconX = startX + float(myIndex) * (iconWorldSize + gap);
// Position: row above the emoji row
iconY = wy - uFontBase * nameWorldScale * uStatusRowOffset;
}
int myIndex = countBelow(iconSlot);
// Horizontal centering: spread icons evenly above the name
float gap = iconWorldSize * 0.15;
float totalWidth = float(totalActive) * iconWorldSize + float(totalActive - 1) * gap;
float startX = wx - totalWidth * 0.5;
float iconX = startX + float(myIndex) * (iconWorldSize + gap);
// Position: row above the emoji row
float iconY = wy - uFontBase * nameWorldScale * uStatusRowOffset;
// Determine atlas index
// Slots 0-6 map directly to atlas indices 0-6
@@ -194,6 +209,9 @@ void main() {
if (iconSlot == 8) {
atlasIdx = 10; // doomsday-clock skull
}
if (isVerifiedSlot) {
atlasIdx = 11; // blue verified check
}
// Only the alliance icon (slot 3) gets the dark outline.
vOutline = (iconSlot == 3) ? 1.0 : 0.0;
+2
View File
@@ -28,6 +28,8 @@ export interface PlayerStatic {
flag?: string;
/** Resolved crown-cosmetic image URL, or undefined for no crown. */
crown?: string;
/** Plays under the verified account username — blue check next to the name. */
verified?: boolean;
/** Hex color (e.g. "#ff0000"). Populated from territoryColor (live) or palette (replay). */
color?: string;
}
+14
View File
@@ -200,6 +200,10 @@ const ClientInfoSchema = z.object({
username: UsernameSchema,
clanTag: ClanTagSchema,
friends: z.array(z.string()).optional(),
// Plays under their server-validated account name (blue check in the
// lobby list). Never set on anonymized entries — the badge vouches for
// the exact display name.
verified: z.boolean().optional(),
});
export const GameInfoSchema = z.object({
@@ -273,6 +277,9 @@ export interface ClientInfo {
username: string;
clanTag: string | null;
friends?: ClientID[];
// Plays under their server-validated account name (blue check). Never set
// on anonymized entries.
verified?: boolean;
}
export enum LogSeverity {
Debug = "DEBUG",
@@ -658,6 +665,11 @@ export const PlayerCosmeticRefsSchema = z.object({
// One selected effect per slot: key = slot (effectType for trails, nukeType for
// nuke explosions — see effectTypeForSlot), value = effect name.
effects: z.record(z.string(), CosmeticNameSchema).optional(),
// The player claims to be playing under their verified account username
// (renders the blue check next to the name). The game server keeps the
// claim only when the join name exactly matches the account's resolved
// display name from /users/@me (Worker join → verifiedBadgeAllowed).
verified: z.boolean().optional(),
});
export const PlayerSkinSchema = z.object({
@@ -689,6 +701,8 @@ export const PlayerCosmeticsSchema = z.object({
// Resolved effects keyed by slot (effectType for trails, nukeType for nuke
// explosions).
effects: z.record(z.string(), PlayerEffectSchema).optional(),
// Plays under the verified account username — renders the blue check.
verified: z.boolean().optional(),
});
export const PlayerSchema = z.object({
+10
View File
@@ -644,6 +644,15 @@ export class GameServer {
);
this.activeClients.push(client);
if (identityUpdate && !this.hasStarted()) {
// The verified badge vouches for the exact join name — a pre-start
// identity change under it must drop the badge (the rejoin path skips
// the Worker's join-time validation).
if (
identityUpdate.username !== client.username &&
client.cosmetics?.verified
) {
delete client.cosmetics.verified;
}
client.username = identityUpdate.username;
client.clanTag = identityUpdate.clanTag;
}
@@ -1240,6 +1249,7 @@ export class GameServer {
clanTag: hideClanTags ? null : (c.clanTag ?? null),
clientID: c.clientID,
friends: friendsFor(c),
verified: c.cosmetics?.verified,
}
: {
username: this.anonName(viewer, c.clientID),
+45 -1
View File
@@ -278,6 +278,13 @@ export class PrivilegeCheckerImpl implements PrivilegeChecker {
}
}
}
// Entitlement-blind pass-through: isAllowed has no user identity. The
// authoritative check — join name must exactly match the account's
// resolved display name — runs at join in Worker.ts using the /users/@me
// response (enforceVerifiedBadge below).
if (refs.verified === true) {
cosmetics.verified = true;
}
return { type: "allowed", cosmetics };
}
@@ -409,7 +416,13 @@ const defaultMatcher = createMatcher(baselineBannedWords);
export class FailOpenPrivilegeChecker implements PrivilegeChecker {
isAllowed(flares: string[], refs: PlayerCosmeticRefs): CosmeticResult {
return { type: "allowed", cosmetics: {} };
// Catalog cosmetics can't be resolved without the cosmetics data, but the
// verified claim isn't a catalog item — pass it through; the Worker's
// enforceVerifiedBadge still validates it against the account at join.
return {
type: "allowed",
cosmetics: refs.verified === true ? { verified: true } : {},
};
}
censor(
@@ -429,3 +442,34 @@ export class FailOpenPrivilegeChecker implements PrivilegeChecker {
return { tag: censoredTag, dropped: false };
}
}
/**
* Enforce the client-claimed verified badge on resolved cosmetics. The claim
* is kept only when the account vouches for it: an entitled bare-name status
* (premium/indefinite) AND a join name EXACTLY matching the account's
* server-resolved display name the client locks the input to that form, so
* any drift (a rename race, a censor rewrite, a hand-crafted join message)
* drops the badge. Strips, never rejects.
*
* `account` is the /users/@me player the Worker already fetches for flares;
* null means an anonymous persistent-ID join those only exist in Dev, where
* the claim is kept so the badge stays locally testable.
*
* Returns true when an unvouched claim was stripped (for logging).
*/
export function enforceVerifiedBadge(
cosmetics: PlayerCosmetics,
joinUsername: string,
account: { username?: string | null; usernameStatus?: string } | null,
): boolean {
if (cosmetics.verified !== true) return false;
const vouched =
account === null ||
((account.usernameStatus === "premium" ||
account.usernameStatus === "indefinite") &&
typeof account.username === "string" &&
account.username === joinUsername);
if (vouched) return false;
delete cosmetics.verified;
return true;
}
+20
View File
@@ -26,6 +26,7 @@ import { registerGamePreviewRoute } from "./GamePreviewRoute";
import type { GameServer } from "./GameServer";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { enforceVerifiedBadge } from "./Privilege";
import { MapPlaylist } from "./MapPlaylist";
import { setNoStoreHeaders } from "./NoStoreHeaders";
@@ -509,6 +510,9 @@ export async function startWorker() {
let publicId: string | undefined;
let friends: string[] = [];
let ownedClanTags: string[] = [];
let accountUsername:
| { username?: string | null; usernameStatus?: string }
| undefined;
const allowedFlares = ServerEnv.allowedFlares();
if (claims === null) {
@@ -532,6 +536,7 @@ export async function startWorker() {
publicId = result.response.player.publicId;
friends = result.response.player.friends;
ownedClanTags = result.response.player.clans?.map((c) => c.tag) ?? [];
accountUsername = result.response.player;
if (allowedFlares !== undefined) {
const allowed =
@@ -575,6 +580,21 @@ export async function startWorker() {
return;
}
// An undefined account means an anonymous persistent-ID join (no
// /users/@me fetch) — enforceVerifiedBadge treats that as Dev-only.
if (
enforceVerifiedBadge(
cosmeticResult.cosmetics,
censoredUsername,
accountUsername ?? null,
)
) {
log.info("Stripped unvouched verified-badge claim", {
persistentID: persistentId,
gameID: clientMsg.gameID,
});
}
// Turnstile gates the FIRST join only. An already-admitted player who
// reconnects (e.g. a socket drop during the lobby->start transition,
// after which the server has cleared their reconnection mapping) must
+37 -1
View File
@@ -13,7 +13,11 @@ import {
SubscriptionSchema,
TrailEffectAttributesSchema,
} from "../src/core/CosmeticSchemas";
import { PlayerEffectSchema } from "../src/core/Schemas";
import {
PlayerCosmeticRefsSchema,
PlayerCosmeticsSchema,
PlayerEffectSchema,
} from "../src/core/Schemas";
describe("Effect cosmetic schemas", () => {
const base = {
@@ -964,3 +968,35 @@ describe("SubscriptionSchema canCreatePublicLobbies", () => {
).toBe(false);
});
});
describe("verified badge on cosmetics schemas", () => {
it("accepts a verified claim on refs and resolved cosmetics", () => {
const refs = PlayerCosmeticRefsSchema.safeParse({ verified: true });
expect(refs.success).toBe(true);
if (refs.success) {
expect(refs.data.verified).toBe(true);
}
const resolved = PlayerCosmeticsSchema.safeParse({ verified: true });
expect(resolved.success).toBe(true);
if (resolved.success) {
expect(resolved.data.verified).toBe(true);
}
});
it("stays optional (old clients omit it)", () => {
const refs = PlayerCosmeticRefsSchema.safeParse({});
expect(refs.success).toBe(true);
if (refs.success) {
expect(refs.data.verified).toBeUndefined();
}
});
it("rejects a non-boolean verified", () => {
expect(
PlayerCosmeticRefsSchema.safeParse({ verified: "yes" }).success,
).toBe(false);
expect(PlayerCosmeticsSchema.safeParse({ verified: 1 }).success).toBe(
false,
);
});
});
+99
View File
@@ -1,5 +1,6 @@
import {
createMatcher,
enforceVerifiedBadge,
FailOpenPrivilegeChecker,
PrivilegeCheckerImpl,
shadowNames,
@@ -496,6 +497,104 @@ describe("Flag validation in isAllowed", () => {
});
});
describe("Verified badge in isAllowed", () => {
test("passes through a verified claim", () => {
const result = flagChecker.isAllowed([], { verified: true });
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.verified).toBe(true);
}
});
test("stays unset when absent or false", () => {
for (const refs of [{}, { verified: false }]) {
const result = flagChecker.isAllowed([], refs);
expect(result.type).toBe("allowed");
if (result.type === "allowed") {
expect(result.cosmetics.verified).toBeUndefined();
}
}
});
});
describe("enforceVerifiedBadge", () => {
test("keeps the badge for an entitled player joining under their exact bare name", () => {
for (const usernameStatus of ["premium", "indefinite"]) {
const cosmetics = { verified: true };
expect(
enforceVerifiedBadge(cosmetics, "Bob", {
username: "Bob",
usernameStatus,
}),
).toBe(false);
expect(cosmetics.verified).toBe(true);
}
});
test("strips on a case-different join name (exact match only)", () => {
const cosmetics = { verified: true };
expect(
enforceVerifiedBadge(cosmetics, "bob", {
username: "Bob",
usernameStatus: "premium",
}),
).toBe(true);
expect(cosmetics.verified).toBeUndefined();
});
test("strips on a different name entirely", () => {
const cosmetics = { verified: true };
expect(
enforceVerifiedBadge(cosmetics, "Alice", {
username: "Bob",
usernameStatus: "premium",
}),
).toBe(true);
expect(cosmetics.verified).toBeUndefined();
});
test("strips unentitled statuses even on an exact match", () => {
for (const usernameStatus of ["unclaimed", "claimed", undefined]) {
const cosmetics = { verified: true };
expect(
enforceVerifiedBadge(cosmetics, "Bob.4821", {
username: "Bob.4821",
usernameStatus,
}),
).toBe(true);
expect(cosmetics.verified).toBeUndefined();
}
});
test("strips when the account has no username set", () => {
for (const account of [
{ username: null, usernameStatus: "premium" },
{ usernameStatus: "premium" },
]) {
const cosmetics = { verified: true };
expect(enforceVerifiedBadge(cosmetics, "Bob", account)).toBe(true);
expect(cosmetics.verified).toBeUndefined();
}
});
test("keeps the badge on an anonymous join (null account, Dev-only)", () => {
const cosmetics = { verified: true };
expect(enforceVerifiedBadge(cosmetics, "Whatever", null)).toBe(false);
expect(cosmetics.verified).toBe(true);
});
test("no-op without a claim", () => {
for (const cosmetics of [{}, { verified: false }]) {
expect(
enforceVerifiedBadge(cosmetics, "Bob", {
username: "Other",
usernameStatus: "unclaimed",
}),
).toBe(false);
}
});
});
describe("Skin validation", () => {
describe("isSkinAllowed (direct)", () => {
test("returns skin when user has wildcard flare", () => {
+37 -4
View File
@@ -21,6 +21,7 @@ function makeClient(
role: string | null = null,
publicId: string | undefined = undefined,
friends: string[] = [],
cosmetics: { verified?: boolean } | undefined = undefined,
): Client {
return new Client(
clientID,
@@ -32,7 +33,7 @@ function makeClient(
username,
clanTag,
makeMockWs() as any,
undefined,
cosmetics,
publicId,
friends,
);
@@ -67,9 +68,17 @@ function makeGame(
[
makeClient("creator", "creator-pid", "CreatorReal", "HOST"),
makeClient("admin", "admin-pid", "AdminReal", "ADM", "admin"),
makeClient("alice", "alice-pid", "AliceReal", "AAA", null, "alice-pub", [
"bob-pub",
]),
makeClient(
"alice",
"alice-pid",
"AliceReal",
"AAA",
null,
"alice-pub",
["bob-pub"],
// Join-time validated cosmetics (enforceVerifiedBadge already ran).
{ verified: true },
),
makeClient("bob", "bob-pid", "BobReal", "BBB", null, "bob-pub"),
].forEach((c) => game.joinClient(c));
return game;
@@ -155,6 +164,30 @@ describe("anonymizeNames: gameInfo (lobby / HTTP / preview)", () => {
});
});
describe("verified badge in gameInfo", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
it("real entries carry verified from the join-validated cosmetics", () => {
const info = makeGame(false).gameInfo("bob");
expect(byId(info, "alice").verified).toBe(true);
expect(byId(info, "bob").verified).toBeUndefined();
});
it("anonymized entries never carry verified", () => {
const info = makeGame(true).gameInfo("bob");
expect(byId(info, "alice").verified).toBeUndefined();
});
it("the anonymized player still sees their own badge", () => {
const info = makeGame(true).gameInfo("alice");
expect(byId(info, "alice").verified).toBe(true);
});
});
describe("anonymizeNames: config updates propagate", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {