engagement-worker.js (10704B)
1 // Coarse debounce for the durable `lastSeenAt` write on the message hot path. 2 // The room TTL only needs second-ish granularity, so a write + alarm reschedule 3 // on every keystroke broadcast is pure churn. In-memory, so it resets to a 4 // guaranteed write on each hibernation wake — which is fine. 5 const TOUCH_DEBOUNCE_MS = 30 * 1000; 6 7 export class EngagementRoom { 8 constructor(state, env) { 9 this.state = state; 10 this.env = env; 11 // Answer keepalive pings without waking the Durable Object, so an otherwise 12 // idle foreground socket stays warm through NAT / edge idle timeouts. Pairs 13 // with the client's periodic "ping" (EngagementHost.startPing). Auto-answered 14 // frames never reach `webSocketMessage`, so they cost nothing here. 15 this.state.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong")); 16 } 17 18 async fetch(request) { 19 const url = new URL(request.url); 20 const route = roomRouteFromPath(url.pathname); 21 if (!route) { 22 return new Response("Missing room ID", { status: 400 }); 23 } 24 25 if (route.endpoint === "register") { 26 if (request.method !== "POST") { 27 return new Response("Method not allowed", { status: 405 }); 28 } 29 return this.register(request); 30 } 31 32 const upgrade = request.headers.get("Upgrade"); 33 if (upgrade !== "websocket") { 34 return new Response("Expected WebSocket upgrade", { status: 426 }); 35 } 36 37 const auth = await this.authenticate(route.roomID, url.searchParams); 38 if (!auth.ok) { 39 return new Response(auth.message, { status: auth.status }); 40 } 41 42 const pair = new WebSocketPair(); 43 const [client, server] = Object.values(pair); 44 server.serializeAttachment({ 45 authorID: auth.authorID, 46 deviceID: auth.deviceID, 47 connectedAt: Date.now() 48 }); 49 // Supersede any earlier socket from the same device. A reconnect would 50 // otherwise leave the stale one lingering until its TCP dies, so the room 51 // fans every broadcast at a zombie and can echo the device's own prior 52 // frames back to it. `server` is not accepted yet, so it is not in the set. 53 for (const existing of this.state.getWebSockets()) { 54 const attachment = existing.deserializeAttachment(); 55 if (attachment && attachment.authorID === auth.authorID && attachment.deviceID === auth.deviceID) { 56 try { 57 existing.close(1000, "Superseded by a newer connection"); 58 } catch { 59 // Already closing; the runtime will reap it. 60 } 61 } 62 } 63 this.state.acceptWebSocket(server); 64 65 return new Response(null, { 66 status: 101, 67 webSocket: client 68 }); 69 } 70 71 // Registers the room secret ahead of a socket connect. First write wins: 72 // the secret is created if absent, confirmed if it matches, and refused if 73 // it differs. The secret arrives in the request body, never in a URL. 74 // 75 // Every legitimate holder of the room creds (distributed via the shared 76 // CloudKit Game record) registers idempotently before each connect, so an 77 // idle-expired room is resurrected by whichever peer returns first and 78 // there is no ordering race between the minter and its peers. An attacker 79 // who learns a room ID (it appears in URL paths) but not the secret can 80 // neither register over a live room nor sign a connect. 81 async register(request) { 82 let body; 83 try { 84 body = await request.json(); 85 } catch { 86 return new Response("Invalid JSON body", { status: 400 }); 87 } 88 const secret = typeof body.secret === "string" ? body.secret : ""; 89 if (!isAcceptableSecret(secret)) { 90 return new Response("Invalid secret", { status: 400 }); 91 } 92 93 const stored = await this.state.storage.get("secret"); 94 if (stored) { 95 if (!timingSafeEqual(stored, secret)) { 96 return new Response("Room secret mismatch", { status: 409 }); 97 } 98 } else { 99 await this.state.storage.put("secret", secret); 100 await this.state.storage.put("createdAt", Date.now()); 101 // TOFU-era rooms stored only a connect-time hash; the registered 102 // secret supersedes it. 103 await this.state.storage.delete("secretHash"); 104 } 105 106 await this.state.storage.put("lastSeenAt", Date.now()); 107 await this.scheduleExpiry(); 108 return new Response(null, { status: stored ? 204 : 201 }); 109 } 110 111 async authenticate(roomID, params) { 112 const authorID = params.get("authorID") || ""; 113 const deviceID = params.get("deviceID") || ""; 114 const timestamp = params.get("timestamp") || ""; 115 const nonce = params.get("nonce") || ""; 116 const signature = params.get("signature") || ""; 117 118 if (!authorID || !deviceID || !timestamp || !nonce || !signature) { 119 return { ok: false, status: 401, message: "Missing auth parameters" }; 120 } 121 122 // The secret never travels on a connect; it must have been registered 123 // via `register` (clients re-register idempotently before each connect, 124 // which also resurrects a room whose idle expiry wiped this storage). 125 const secret = await this.state.storage.get("secret"); 126 if (!secret) { 127 return { ok: false, status: 403, message: "Room not registered" }; 128 } 129 130 const nowSeconds = Math.floor(Date.now() / 1000); 131 const timestampSeconds = Number(timestamp); 132 const maxSkewSeconds = Number(this.env.MAX_AUTH_SKEW_SECONDS || "120"); 133 if (!Number.isFinite(timestampSeconds) || Math.abs(nowSeconds - timestampSeconds) > maxSkewSeconds) { 134 return { ok: false, status: 401, message: "Stale auth timestamp" }; 135 } 136 137 const nonceKey = `nonce:${nonce}`; 138 if (await this.state.storage.get(nonceKey)) { 139 return { ok: false, status: 401, message: "Nonce already used" }; 140 } 141 142 const payload = [roomID, authorID, deviceID, timestamp, nonce].join("|"); 143 const expectedSignature = await hmacSHA256(secret, payload); 144 if (!timingSafeEqual(signature, expectedSignature)) { 145 return { ok: false, status: 401, message: "Invalid signature" }; 146 } 147 148 await this.state.storage.put("lastSeenAt", Date.now()); 149 await this.state.storage.put(nonceKey, Date.now()); 150 await this.pruneNonces(); 151 await this.scheduleExpiry(); 152 this.lastTouchAt = Date.now(); 153 154 return { ok: true, authorID, deviceID }; 155 } 156 157 async webSocketMessage(ws, message) { 158 const data = typeof message === "string" ? message : message.slice(0); 159 await this.touch(); 160 for (const peer of this.state.getWebSockets()) { 161 if (peer === ws) continue; 162 try { 163 peer.send(data); 164 } catch { 165 // A peer caught mid-close throws here; skip it so the rest of the room 166 // still receives this frame. The dead socket is reaped via 167 // `webSocketClose` / runtime cleanup. 168 } 169 } 170 } 171 172 async webSocketClose(ws, code, reason) { 173 await this.state.storage.put("lastSeenAt", Date.now()); 174 await this.scheduleExpiry(); 175 ws.close(code, reason); 176 } 177 178 async alarm() { 179 await this.pruneNonces(); 180 const ttlMs = Number(this.env.ROOM_TTL_SECONDS || "600") * 1000; 181 const sockets = this.state.getWebSockets(); 182 if (sockets.length > 0) { 183 // Still connected — the room is alive regardless of how long since the 184 // last broadcast (keepalive pings are auto-answered and don't advance 185 // `lastSeenAt`). Re-arm from now, not the stale deadline, or a long idle 186 // session would refire the alarm in a tight loop. 187 await this.state.storage.setAlarm(Date.now() + ttlMs); 188 return; 189 } 190 191 const lastSeenAt = await this.state.storage.get("lastSeenAt"); 192 const idleMs = Date.now() - (lastSeenAt || Date.now()); 193 if (idleMs > ttlMs) { 194 await this.state.storage.deleteAll(); 195 return; 196 } 197 await this.scheduleExpiry(); 198 } 199 200 async touch() { 201 const now = Date.now(); 202 // Debounce the durable write + alarm reschedule off the message hot path. 203 if (this.lastTouchAt && now - this.lastTouchAt < TOUCH_DEBOUNCE_MS) return; 204 this.lastTouchAt = now; 205 await this.state.storage.put("lastSeenAt", now); 206 await this.scheduleExpiry(); 207 } 208 209 async pruneNonces() { 210 const maxAgeMs = Number(this.env.NONCE_TTL_SECONDS || "300") * 1000; 211 const cutoff = Date.now() - maxAgeMs; 212 const nonces = await this.state.storage.list({ prefix: "nonce:" }); 213 for (const [key, createdAt] of nonces) { 214 if (createdAt < cutoff) { 215 await this.state.storage.delete(key); 216 } 217 } 218 } 219 220 async scheduleExpiry() { 221 const ttlMs = Number(this.env.ROOM_TTL_SECONDS || "600") * 1000; 222 const lastSeenAt = (await this.state.storage.get("lastSeenAt")) || Date.now(); 223 await this.state.storage.setAlarm(lastSeenAt + ttlMs); 224 } 225 } 226 227 export default { 228 async fetch(request, env) { 229 const url = new URL(request.url); 230 if (url.pathname === "/health") { 231 return new Response("ok"); 232 } 233 const route = roomRouteFromPath(url.pathname); 234 if (!route) { 235 return new Response("Not found", { status: 404 }); 236 } 237 const id = env.ENGAGEMENT_ROOMS.idFromName(route.roomID); 238 return env.ENGAGEMENT_ROOMS.get(id).fetch(request); 239 } 240 }; 241 242 function roomRouteFromPath(pathname) { 243 const match = pathname.match(/^\/rooms\/([^/]+)\/(socket|register)$/); 244 return match ? { roomID: match[1], endpoint: match[2] } : null; 245 } 246 247 // The secret doubles as the HMAC key for connect signatures, so a registered 248 // value must decode to at least 32 key bytes (clients mint exactly 32). 249 function isAcceptableSecret(secret) { 250 if (!secret) return false; 251 let bytes; 252 try { 253 bytes = base64URLDecode(secret); 254 } catch { 255 return false; 256 } 257 return bytes.length >= 32; 258 } 259 260 async function hmacSHA256(secret, payload) { 261 const key = await crypto.subtle.importKey( 262 "raw", 263 base64URLDecode(secret), 264 { name: "HMAC", hash: "SHA-256" }, 265 false, 266 ["sign"] 267 ); 268 const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload)); 269 return base64URLEncode(new Uint8Array(signature)); 270 } 271 272 function base64URLDecode(value) { 273 const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); 274 const binary = atob(base64); 275 return Uint8Array.from(binary, (char) => char.charCodeAt(0)); 276 } 277 278 function base64URLEncode(bytes) { 279 let binary = ""; 280 for (const byte of bytes) { 281 binary += String.fromCharCode(byte); 282 } 283 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); 284 } 285 286 function timingSafeEqual(a, b) { 287 const left = new TextEncoder().encode(a); 288 const right = new TextEncoder().encode(b); 289 if (left.length !== right.length) return false; 290 let diff = 0; 291 for (let index = 0; index < left.length; index += 1) { 292 diff |= left[index] ^ right[index]; 293 } 294 return diff === 0; 295 }