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');