Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/server/GameServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -795,9 +795,10 @@ export class GameServer {
}

// Attempt to reconnect a client by persistentID. Returns true if successful.
// WebSocket is always updated. Identity updates — already screened by the
// caller (join_verify, or the local fallback censor) — are applied only
// before the game has started.
// WebSocket is always updated. Identity updates — already screened AND
// clan-tag-ownership-resolved by the caller (join_verify only censors; the
// Worker must run resolveClanTag first) — are applied only before the game
// has started.
public rejoinClient(
ws: WebSocket,
persistentID: string,
Expand Down
78 changes: 49 additions & 29 deletions src/server/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,25 +544,6 @@ export async function startWorker() {
}
}

// Try to reconnect an existing client (e.g., page refresh) with the
// screened identity — before the game starts, a refresh under a new
// name updates the displayed identity like a fresh join would. When
// the verify was skipped, no identity update is passed: the stored
// identity was screened at admission and must not be clobbered by
// the coarser local fallback.
// If successful, skip the rest of the join authorization.
if (
gm.rejoinClient(
ws,
persistentId,
clientMsg.gameID,
0,
verifySkipped ? undefined : { username, clanTag },
)
) {
return;
}

let flares: string[] | undefined;
let publicId: string | undefined;
let friends: string[] = [];
Expand All @@ -572,13 +553,18 @@ export async function startWorker() {
| undefined;

const allowedFlares = ServerEnv.allowedFlares();
if (claims === null) {
if (allowedFlares !== undefined) {
log.warn("Unauthorized: Anonymous user attempted to join game");
ws.close(1002, "Unauthorized");
return;
// Fetch the account (flares, friends, clan memberships) and run the
// allowed-flares gate. Closes the socket and returns false when the
// join must not proceed.
const loadAccount = async (): Promise<boolean> => {
if (claims === null) {
if (allowedFlares !== undefined) {
log.warn("Unauthorized: Anonymous user attempted to join game");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Localize the close reason.

ws.close() sends "Unauthorized" to the client. Route this text through translateText() and add its English resource entry.

As per coding guidelines, “All user-visible text must go through translateText() and have a corresponding entry in resources/lang/en.json.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/Worker.ts` at line 562, Update the unauthorized join handling in
Worker.ts so the close reason passed to ws.close() uses translateText() instead
of a hardcoded “Unauthorized” string, and add the matching English resource
entry in resources/lang/en.json.

Source: Coding guidelines

ws.close(1002, "Unauthorized");
return false;
}
return true;
}
} else {
// Verify token and get player permissions
const result = await getUserMe(clientMsg.token);
if (result.type === "error") {
Expand All @@ -587,7 +573,7 @@ export async function startWorker() {
gameID: clientMsg.gameID,
});
ws.close(1002, "Unauthorized: user me fetch failed");
return;
return false;
}
flares = result.response.player.flares;
publicId = result.response.player.publicId;
Expand All @@ -604,14 +590,34 @@ export async function startWorker() {
"Forbidden: player without an allowed flare attempted to join game",
);
ws.close(1002, "Forbidden");
return;
return false;
}
}
return true;
};

// A skipped verify carries no identity update — the stored identity
// was screened at admission and must not be clobbered by the coarser
// local fallback — so the reconnect completes with zero API calls,
// keeping mass reconnects at game start off the API.
if (
verifySkipped &&
gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0)
) {
return;
}

if (!(await loadAccount())) {
return;
}

// Enforce clan tag ownership: a player can wear a tag only if they're
// a member; a real clan they're not in (or an unverifiable tag) is
// dropped to prevent impersonation. Fictional tags pass through.
// SECURITY: this must run BEFORE the identity-updating rejoin below —
// join_verify only censors (it has no membership data), so passing
// its tag through unresolved would let a pre-start refresh re-apply
// a reserved tag that was dropped at admission.
const resolution = privilegeRefresher
.get()
.resolveClanTag(clanTag, ownedClanTags);
Expand All @@ -622,7 +628,21 @@ export async function startWorker() {
clanTag,
});
}
const resolvedClanTag = resolution.tag;
clanTag = resolution.tag;

// Try to reconnect an existing client (e.g., page refresh) with the
// screened, ownership-resolved identity — before the game starts, a
// refresh under a new name updates the displayed identity like a
// fresh join would. If successful, skip the rest of the join
// authorization.
if (
gm.rejoinClient(ws, persistentId, clientMsg.gameID, 0, {
username,
clanTag,
})
) {
return;
}

const cosmeticResult = privilegeRefresher
.get()
Expand Down Expand Up @@ -661,7 +681,7 @@ export async function startWorker() {
flares,
ip,
username,
resolvedClanTag,
clanTag,
ws,
cosmeticResult.cosmetics,
publicId,
Expand Down
Loading