commit 70dee145ff565848cf0e5c112a919c982ab7042d
parent 013dcc8172d15dc5c492bddeab496531641829ff
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Jul 2026 03:48:34 +0900
Limit room creation and shared replay shortcuts
This commit bounds two paths that could previously trust incomplete
local state. Room registration now passes through a Durable Object
sliding window before a new engagement room is created, keyed by source
IP and distinct room ID. Re-registering the same room remains
idempotent, while rotating through fresh room IDs returns 429 with
Retry-After.
Shared finished-game replays now skip the local-only shortcut even when
this device has not caught up with the other Moves rows yet. The Success
Panel defers to the merged replay loader for shared games, so it can
wait for every contributing device's journal instead of memoising an
incomplete local timeline.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
3 files changed, 130 insertions(+), 16 deletions(-)
diff --git a/Crossmate/CrossmateApp.swift b/Crossmate/CrossmateApp.swift
@@ -775,29 +775,24 @@ private struct PuzzleDisplayView: View {
},
store: { services.replays.cacheReplayTimeline($0, gameID: gameID) }
) {
- // Local-first: if no *other* device wrote into this
- // game, this device's journal is the whole history,
- // so replay needs no CloudKit. `contributingDevices`
- // reads the per-device MovesEntity rows — the
- // device-level signal the author-keyed roster can't
- // give, so it sees this account's own second device,
- // not just other people. Any other contributor →
- // merged fetch, which gates on every contributing
- // device's journal.
+ // Local-first only for unshared games: this device's
+ // journal is the whole history, so replay needs no
+ // CloudKit. Shared games always use the merged
+ // loader, even if their Moves rows have not caught
+ // up locally yet, so replay can wait for every
+ // contributing device's journal instead of caching
+ // an incomplete local timeline.
let entries = store.localJournalEntries(for: gameID)
- let localDeviceID = RecordSerializer.localDeviceID
- let otherDevices = store.contributingDevices(for: gameID)
- .filter { $0.deviceID != localDeviceID }
- if otherDevices.isEmpty {
+ if !store.isGameShared(gameID: gameID) {
services.syncMonitor.note(
"replay[\(short)]: local-only path " +
- "(no other contributing devices), localEntries=\(entries.count)"
+ "(unshared game), localEntries=\(entries.count)"
)
return .ready(ReplayTimeline(merging: [entries]))
}
services.syncMonitor.note(
- "replay[\(short)]: merged path, " +
- "otherDevices=\(otherDevices.count), localEntries=\(entries.count)"
+ "replay[\(short)]: shared merged path, " +
+ "localEntries=\(entries.count)"
)
return await services.replays.loadReplay(gameID: gameID)
}
diff --git a/Workers/room-worker.js b/Workers/room-worker.js
@@ -4,6 +4,57 @@
// guaranteed write on each hibernation wake — which is fine.
const TOUCH_DEBOUNCE_MS = 30 * 1000;
+export class EngagementRegisterLimiter {
+ constructor(state, env) {
+ this.state = state;
+ this.env = env;
+ }
+
+ async fetch(request) {
+ const url = new URL(request.url);
+ const roomID = url.searchParams.get("roomID") || "";
+ if (!roomID) {
+ return new Response("Missing room ID", { status: 400 });
+ }
+
+ const result = await this.checkRoomRegisterRateLimit(request, roomID);
+ return result.ok ? new Response(null, { status: 204 }) : rateLimitedResponse(result);
+ }
+
+ async checkRoomRegisterRateLimit(request, roomID) {
+ const ip = request.headers.get("CF-Connecting-IP") || "unknown";
+ const config = rateLimitConfig(this.env, "ROOM_REGISTER_IP", 60, 60 * 60);
+ const now = Date.now();
+ const windowMillis = config.windowSeconds * 1000;
+ const cutoff = now - windowMillis;
+ const keySecret = rateLimitKeySecret(this.env);
+ const key = await rateLimitStorageKey("room-register:ip", ip, keySecret);
+ const roomToken = await rateLimitToken(roomID, keySecret);
+ const stored = await this.state.storage.get(key);
+ const recent = Array.isArray(stored)
+ ? stored.filter((entry) => entry && typeof entry.at === "number" && entry.at > cutoff && typeof entry.room === "string")
+ : [];
+ recent.sort((left, right) => left.at - right.at);
+
+ const existing = recent.find((entry) => entry.room === roomToken);
+ if (existing) {
+ existing.at = now;
+ await this.state.storage.put(key, recent);
+ return { ok: true };
+ }
+
+ if (recent.length >= config.limit) {
+ const retryAfterSeconds = Math.max(1, Math.ceil((recent[0].at + windowMillis - now) / 1000));
+ await this.state.storage.put(key, recent);
+ return { ok: false, retryAfterSeconds };
+ }
+
+ recent.push({ room: roomToken, at: now });
+ await this.state.storage.put(key, recent);
+ return { ok: true };
+ }
+}
+
export class EngagementRoom {
constructor(state, env) {
this.state = state;
@@ -234,6 +285,10 @@ export default {
if (!route) {
return new Response("Not found", { status: 404 });
}
+ if (route.endpoint === "register" && request.method === "POST") {
+ const limited = await checkRegisterRateLimit(request, env, route.roomID);
+ if (limited) return limited;
+ }
const id = env.ENGAGEMENT_ROOMS.idFromName(route.roomID);
return env.ENGAGEMENT_ROOMS.get(id).fetch(request);
}
@@ -244,6 +299,60 @@ function roomRouteFromPath(pathname) {
return match ? { roomID: match[1], endpoint: match[2] } : null;
}
+async function checkRegisterRateLimit(request, env, roomID) {
+ if (!env.ENGAGEMENT_REGISTER_LIMITER) return null;
+ const id = env.ENGAGEMENT_REGISTER_LIMITER.idFromName("register");
+ const url = new URL(request.url);
+ url.searchParams.set("roomID", roomID);
+ const response = await env.ENGAGEMENT_REGISTER_LIMITER.get(id).fetch(
+ new Request(url.toString(), {
+ method: "POST",
+ headers: request.headers
+ })
+ );
+ return response.status === 204 ? null : response;
+}
+
+function rateLimitConfig(env, prefix, defaultLimit, defaultWindowSeconds) {
+ const limit = Number(env[`${prefix}_LIMIT`] || String(defaultLimit));
+ const windowSeconds = Number(env[`${prefix}_WINDOW_SECONDS`] || String(defaultWindowSeconds));
+ return {
+ limit: Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : defaultLimit,
+ windowSeconds: Number.isFinite(windowSeconds) && windowSeconds > 0
+ ? Math.floor(windowSeconds)
+ : defaultWindowSeconds
+ };
+}
+
+function rateLimitedResponse(result) {
+ return new Response("Rate limit exceeded", {
+ status: 429,
+ headers: {
+ "Retry-After": String(result.retryAfterSeconds)
+ }
+ });
+}
+
+async function rateLimitStorageKey(bucket, identity, secret) {
+ return `rate:${bucket}:${await rateLimitToken(identity, secret)}`;
+}
+
+async function rateLimitToken(identity, secret) {
+ const key = await crypto.subtle.importKey(
+ "raw",
+ new TextEncoder().encode(secret),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"]
+ );
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(String(identity || "")));
+ return base64URLEncode(new Uint8Array(signature));
+}
+
+function rateLimitKeySecret(env) {
+ return env.RATE_LIMIT_HASH_KEY || "crossmate-room-rate-limit-v1";
+}
+
// The secret doubles as the HMAC key for connect signatures, so a registered
// value must decode to at least 32 key bytes (clients mint exactly 32).
function isAcceptableSecret(secret) {
diff --git a/Workers/wrangler.room.toml b/Workers/wrangler.room.toml
@@ -7,11 +7,21 @@ workers_dev = true
ROOM_TTL_SECONDS = "600"
NONCE_TTL_SECONDS = "300"
MAX_AUTH_SKEW_SECONDS = "120"
+ROOM_REGISTER_IP_LIMIT = "60"
+ROOM_REGISTER_IP_WINDOW_SECONDS = "3600"
[[durable_objects.bindings]]
name = "ENGAGEMENT_ROOMS"
class_name = "EngagementRoom"
+[[durable_objects.bindings]]
+name = "ENGAGEMENT_REGISTER_LIMITER"
+class_name = "EngagementRegisterLimiter"
+
[[migrations]]
tag = "v1"
new_sqlite_classes = ["EngagementRoom"]
+
+[[migrations]]
+tag = "v2"
+new_sqlite_classes = ["EngagementRegisterLimiter"]