zebra-spaces: serialize mod actions — fix two-kick-in-a-row "stale epoch"
Server increments roomEpoch on every successful mod action and the new value rides back on the next 'state' broadcast. Two kicks fired in rapid succession both signed with the same epoch N — first succeeds (server now at N+1), second rejected with "stale epoch" because the client hasn't received the state-update yet. Repro 2026-06-04: host kicked two phones, only one was actually evicted; signal log showed 1 AUDIT + 1 /internal/evict + "signal: stale epoch" client-side. Fix: serialize mod-action sends with a promise that resolves on the next 'state' broadcast or 1.5s timeout. signBytes() runs AFTER the wait so the signature uses the freshest known roomEpoch. Applied to every mod action (invite/grant/promote/demote/mute/kick/ban) for defense in depth — any pair of mod actions had the same race. Adds 'mod[label] epoch=N (queue ready)' and 'mod[label] settled epoch=N+1' breadcrumbs so the page log shows the queue draining in real time.
This commit is contained in:
parent
a08243a24b
commit
8d14873ed9
1 changed files with 80 additions and 18 deletions
|
|
@ -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');
|
|||
|
||||
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-04</span><br>
|
||||
md5 <span class="stamp-md5">dbb0b8148bf501d93478f23305ede777</span><br>
|
||||
sha256 <span class="stamp-sha">e75528626e808db2c85749679e01ac45a3f308666d7fe606c370b5672d56201f</span><br>
|
||||
md5 <span class="stamp-md5">ac81473f271b2df615e659a510a042b0</span><br>
|
||||
sha256 <span class="stamp-sha">85e48d687bf5e4aae15997e165a08769ad99a93213436b97b695feada96b25e6</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
||||
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
||||
</footer>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue