diff --git a/web/zebra-spaces.html b/web/zebra-spaces.html index b185773..d911724 100644 --- a/web/zebra-spaces.html +++ b/web/zebra-spaces.html @@ -3548,7 +3548,12 @@ async function handleSignal(raw){ try { send({ type: 'mic-state-req' }); } catch(_){} break; case 'state': - roomEpoch = m.epoch; applyState(m.state); flushSfuStreams(); renderRoom(); break; + roomEpoch = m.epoch; applyState(m.state); flushSfuStreams(); renderRoom(); + /* fresh epoch arrived — release the mod-action queue gate so the + * next mod action signs against this new value rather than the + * stale one from before our previous action's server-side bump. */ + resolvePendingStateUpdate(); + break; case 'mic-state-req': /* Another peer just (re)joined and asked the room to re-announce. * Only speakers with a live mic respond — listeners have no @@ -4119,24 +4124,75 @@ async function onSDP(fromUUID, kind, json){ /* ================================================================== * mod actions — signed messages sent to the server - * ================================================================== */ + * + * Epoch race: every successful mod action server-side increments + * roomEpoch and the new value rides back on the next 'state' broadcast. + * Two kicks fired in rapid succession both signed with the same epoch + * N — the first succeeds (advances server to N+1), the second is + * rejected with "stale epoch" because the client hasn't received the + * state-update yet. Fox 2026-06-04: tried to kick two phones, only + * one was kicked, signal log showed 1 AUDIT + "signal: stale epoch" + * client-side. + * + * Fix: serialize mod-action sends with a promise that resolves on the + * next 'state' broadcast (or a 1.5s timeout fallback so a missed state + * doesn't permanently wedge the queue). signBytes() is called AFTER + * the wait, so the signature is computed against the freshest known + * roomEpoch. Single-action use is unaffected (queue is empty). */ +let lastModSettled = Promise.resolve(); +let pendingStateResolver = null; +function awaitStateUpdate(){ + /* resolves on next case 'state' arrival, or 1500ms timeout */ + return new Promise(res => { + const t = setTimeout(() => { if (pendingStateResolver === resolver) pendingStateResolver = null; res(); }, 1500); + const resolver = () => { clearTimeout(t); res(); }; + pendingStateResolver = resolver; + }); +} +function resolvePendingStateUpdate(){ + if (!pendingStateResolver) return; + const r = pendingStateResolver; pendingStateResolver = null; r(); +} +async function runModSerial(label, fn){ + /* chain off lastModSettled so the second call waits for the first */ + const prev = lastModSettled; + let settle; + lastModSettled = new Promise(res => { settle = res; }); + try { + await prev.catch(()=>{}); + logLine('', 'mod['+label+'] epoch='+roomEpoch+' (queue ready)'); + await fn(); + /* wait for the state update that bumps roomEpoch so the next + * action signs against the fresh epoch */ + await awaitStateUpdate(); + logLine('', 'mod['+label+'] settled epoch='+roomEpoch); + } finally { settle(); } +} async function modInvite(uuid){ - const sig = await signBytes(sigAction(roomID, roomEpoch, 'mic-invite', uuid)); - send({ type:'mic-invite', to: uuid, epoch: roomEpoch, sig }); + return runModSerial('mic-invite', async () => { + const sig = await signBytes(sigAction(roomID, roomEpoch, 'mic-invite', uuid)); + send({ type:'mic-invite', to: uuid, epoch: roomEpoch, sig }); + }); } /* grant-mic: hand-raised listener doesn't need to accept — server promotes * them directly to speaker. Use modInvite for cold (unsolicited) invites. */ async function modGrant(uuid){ - const sig = await signBytes(sigAction(roomID, roomEpoch, 'grant-mic', uuid)); - send({ type:'grant-mic', to: uuid, epoch: roomEpoch, sig }); + return runModSerial('grant-mic', async () => { + const sig = await signBytes(sigAction(roomID, roomEpoch, 'grant-mic', uuid)); + send({ type:'grant-mic', to: uuid, epoch: roomEpoch, sig }); + }); } async function modPromote(uuid){ - const sig = await signBytes(sigAction(roomID, roomEpoch, 'promote', uuid, 'cohost')); - send({ type:'promote', target: uuid, to: 'cohost', epoch: roomEpoch, sig }); + return runModSerial('promote', async () => { + const sig = await signBytes(sigAction(roomID, roomEpoch, 'promote', uuid, 'cohost')); + send({ type:'promote', target: uuid, to: 'cohost', epoch: roomEpoch, sig }); + }); } async function modDemote(uuid, to){ - const sig = await signBytes(sigAction(roomID, roomEpoch, 'demote', uuid, to)); - send({ type:'demote', target: uuid, to, epoch: roomEpoch, sig }); + return runModSerial('demote→'+to, async () => { + const sig = await signBytes(sigAction(roomID, roomEpoch, 'demote', uuid, to)); + send({ type:'demote', target: uuid, to, epoch: roomEpoch, sig }); + }); } /* mute: drops the speaker's mic publisher at the SFU so their voice * stops immediately. No role change — they keep their seat, keep @@ -4144,8 +4200,10 @@ async function modDemote(uuid, to){ * may unmute themselves on their own when they want to talk again. */ async function modMute(uuid){ if (!confirm('mute this speaker? (they can unmute themselves)')) return; - const sig = await signBytes(sigAction(roomID, roomEpoch, 'mute', uuid)); - send({ type:'mute', target: uuid, epoch: roomEpoch, sig }); + return runModSerial('mute', async () => { + const sig = await signBytes(sigAction(roomID, roomEpoch, 'mute', uuid)); + send({ type:'mute', target: uuid, epoch: roomEpoch, sig }); + }); } /* kick: drop the peer from this session — evicts their SFU PCs (audio * actually stops) but does NOT block their pubkey. They can rejoin @@ -4153,15 +4211,19 @@ async function modMute(uuid){ * wrong-device / hung session that we just want gone right now. */ async function modKick(uuid){ if (!confirm('kick this person? (they can rejoin)')) return; - const sig = await signBytes(sigAction(roomID, roomEpoch, 'kick', uuid)); - send({ type:'kick', target: uuid, epoch: roomEpoch, sig }); + return runModSerial('kick', async () => { + const sig = await signBytes(sigAction(roomID, roomEpoch, 'kick', uuid)); + send({ type:'kick', target: uuid, epoch: roomEpoch, sig }); + }); } /* ban: drop AND block their pubkey for the room's hold window — they * cannot rejoin. Use for actual moderation removals. */ async function modBan(uuid){ if (!confirm('ban this person? (they cannot rejoin)')) return; - const sig = await signBytes(sigAction(roomID, roomEpoch, 'ban', uuid)); - send({ type:'ban', target: uuid, epoch: roomEpoch, sig }); + return runModSerial('ban', async () => { + const sig = await signBytes(sigAction(roomID, roomEpoch, 'ban', uuid)); + send({ type:'ban', target: uuid, epoch: roomEpoch, sig }); + }); } /* ================================================================== @@ -5150,8 +5212,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');