feat(server): host-only custom modifier registration (Q4.4)
Before this commit ANY authenticated player in the room could send custom-modifier.register and have the server accept + broadcast the descriptor — including an opponent mid-match. Fill the 10-slot per-room cap with hostile descriptors, or register a descriptor the host then applies and finds unexpected. Fix: Room gains a hostToken field set at room.create (the creator's token). The register handler gates on room.hostToken === ws.data.token. Non-host registrations are rejected with BAD_TOKEN and a message explaining the gate. Host permissions are stable across reconnects — the host's token is preserved in sessionStorage on the client, so closing + reopening the host's tab retains the permission. A later 'transfer host' flow can mutate hostToken; no such mutation exists today (lobbies have a single creator who remains host for the room's lifetime). New server test 'rejects non-host (opponent) registrations with BAD_TOKEN (Q4.4)' seeds a white+black room, has black try to register (rejected), then white succeeds (proving the gate doesn't leak across players). 1399 → 1400 tests.
This commit is contained in:
parent
019f3987b4
commit
c8d7480a26
3 changed files with 57 additions and 0 deletions
|
|
@ -1527,6 +1527,24 @@ function handleCustomModifierRegister(
|
|||
return;
|
||||
}
|
||||
|
||||
// T3 Q4.4 — host-only gate. Only the room creator's token may
|
||||
// register custom descriptors. Without this an authenticated
|
||||
// opponent could fill the 10-slot cap or ship a hostile
|
||||
// descriptor that the host would then apply. The host's token
|
||||
// is preserved across reconnects, so closing + reopening the
|
||||
// host's tab doesn't revoke the permission.
|
||||
if (room.hostToken !== token) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"BAD_TOKEN",
|
||||
"only the room host can register custom modifiers",
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lazily create the per-room custom registry.
|
||||
if (room.customModifiers === undefined) {
|
||||
room.customModifiers = new Map();
|
||||
|
|
|
|||
|
|
@ -33,6 +33,18 @@ export interface Room {
|
|||
code: string;
|
||||
/** token → player. Using a Map keeps lookup O(1) and keyed on the secret. */
|
||||
players: Map<string, RoomPlayer>;
|
||||
/**
|
||||
* T3 Q4.4 — room host token. Set to the creator's token at
|
||||
* room.create; used to gate host-only operations like
|
||||
* `custom-modifier.register`. Without this check any authenticated
|
||||
* player (including opponents mid-match) could fill the 10-slot
|
||||
* custom-descriptor cap or register hostile descriptors.
|
||||
*
|
||||
* Stays stable across reconnects — the host's token is preserved
|
||||
* in sessionStorage on the client, so a host who closes+reopens
|
||||
* their tab retains host permissions.
|
||||
*/
|
||||
hostToken: string;
|
||||
/** Unix milliseconds the room was created — used for TTL/eviction later. */
|
||||
createdAt: number;
|
||||
/** Rule presets activated for this game (from room.create payload). */
|
||||
|
|
@ -209,6 +221,7 @@ export class RoomRegistry {
|
|||
const room: Room = {
|
||||
code,
|
||||
players: new Map([[token, player]]),
|
||||
hostToken: token,
|
||||
createdAt: Date.now(),
|
||||
// Defensive copy — callers shouldn't be able to mutate our state.
|
||||
rulesetIds: [...rulesetIds],
|
||||
|
|
|
|||
|
|
@ -177,6 +177,32 @@ describe("custom-modifier.register WS handler (T24)", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("rejects non-host (opponent) registrations with BAD_TOKEN (Q4.4)", () => {
|
||||
const { white, black, code } = setupRoom();
|
||||
|
||||
// Black (the non-host opponent) tries to register — rejected.
|
||||
sendClient(black, "custom-modifier.register", {
|
||||
roomCode: code,
|
||||
descriptor: makeDescriptor("custom:opponent-sneak"),
|
||||
});
|
||||
const err = nextMsgOfType(black, "error");
|
||||
expect(err.payload["code"]).toBe("BAD_TOKEN");
|
||||
expect(err.payload["message"]).toMatch(/host/i);
|
||||
|
||||
// Nothing should have been stored.
|
||||
const room = roomRegistry.getRoom(code);
|
||||
expect(room?.customModifiers?.size ?? 0).toBe(0);
|
||||
|
||||
// Host (white) can still register normally — the gate doesn't
|
||||
// leak across players.
|
||||
sendClient(white, "custom-modifier.register", {
|
||||
roomCode: code,
|
||||
descriptor: makeDescriptor("custom:host-allowed"),
|
||||
});
|
||||
nextMsgOfType(white, "custom-modifier.registered");
|
||||
expect(room?.customModifiers?.has("custom:host-allowed")).toBe(true);
|
||||
});
|
||||
|
||||
it("re-register with same id replaces the stored descriptor", () => {
|
||||
const { white, black, code } = setupRoom();
|
||||
const v1 = makeDescriptor("custom:t3-replace", { name: "v1" });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue