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;
|
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.
|
// Lazily create the per-room custom registry.
|
||||||
if (room.customModifiers === undefined) {
|
if (room.customModifiers === undefined) {
|
||||||
room.customModifiers = new Map();
|
room.customModifiers = new Map();
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,18 @@ export interface Room {
|
||||||
code: string;
|
code: string;
|
||||||
/** token → player. Using a Map keeps lookup O(1) and keyed on the secret. */
|
/** token → player. Using a Map keeps lookup O(1) and keyed on the secret. */
|
||||||
players: Map<string, RoomPlayer>;
|
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. */
|
/** Unix milliseconds the room was created — used for TTL/eviction later. */
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
/** Rule presets activated for this game (from room.create payload). */
|
/** Rule presets activated for this game (from room.create payload). */
|
||||||
|
|
@ -209,6 +221,7 @@ export class RoomRegistry {
|
||||||
const room: Room = {
|
const room: Room = {
|
||||||
code,
|
code,
|
||||||
players: new Map([[token, player]]),
|
players: new Map([[token, player]]),
|
||||||
|
hostToken: token,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
// Defensive copy — callers shouldn't be able to mutate our state.
|
// Defensive copy — callers shouldn't be able to mutate our state.
|
||||||
rulesetIds: [...rulesetIds],
|
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", () => {
|
it("re-register with same id replaces the stored descriptor", () => {
|
||||||
const { white, black, code } = setupRoom();
|
const { white, black, code } = setupRoom();
|
||||||
const v1 = makeDescriptor("custom:t3-replace", { name: "v1" });
|
const v1 = makeDescriptor("custom:t3-replace", { name: "v1" });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue