OwnCord Findings Ledger
Generated by render-ledger.mjs. Do not hand-edit — edit findings-ledger.json.
38 open · 0 blocked · 306 fixed · 3 declined · 0 refuted · 1 duplicate
Open
OC-0311 — medium — handleParticipantLeft is channel-blind: a voice_leave for any readable channel mutates this session's E2EE peer state
Client/src/lib/dispatcher.ts:1077 · found 2026-08-22 · hunt general-2026-08-22-b · lens voice-e2ee
The VOICE_LEAVE handler has payload.channel_id in hand (it uses it two lines above for shouldTeardownSession) but calls handleParticipantLeft(payload.user_id) with no channel. E2EEManager.handleParticipantLeft then unconditionally deletes that user from _peerPublicKeys/_peerOfferEpochs, clears their verification badge, retires their key, and re-runs the key-holder election against the client's OWN voice channel — even though the leave was for a different channel entirely. voice_leave is broadcast to channelReadAudience(thatChannel), i.e. every client with READ_MESSAGES on it, not just the room's participants.
Repro: I am in voice channel A and hold the room key; I also have READ_MESSAGES on voice channel B. Peer P (in B) switches to A. Server order: voice_leave(B,P) and voice_state(A,P) are enqueued on the buffered h.broadcast queue by P's voice_join; P's voice_token is sent directly, so P connects and its voice_e2ee_announce is relayed to me via pubsub.Publish. With the broadcast goroutine backed up, my socket sees: (1) announce(P,K) -> I verify P, store K, offer P the current room key; (2) voice_leave(B,P) -> handleParticipantLeft(P) with no channel filter: hadPeerKey=true, P deleted from _peerPublicKeys, clearPeerVerification(P) wipes the verified badge, and because A's roster does not list P yet, retirePeerKey(P,K) retires P's LIVE key; then the wasKeyHolder && hadPeerKey branch rotates the room key excluding P; (3) voice_state(A,P) -> P appears in my voice widget. Result: P is visibly in my call but holds a superseded key — nothing I send decrypts for them and nothing they send decrypts for me. Nothing heals it: mid-call peers never re-announce, my 5-minute rotation iterates _peerPublicKeys (P is gone), and any later replay of P's stored key (e.g. sendVoicePeerKeys on my WS reconnect, hub.go:664-666) is rejected by the retirement guard at livekitE2EE.ts:782. Passing payload.channel_id and ignoring leaves for other channels fixes it; the ready-resync call site at dispatcher.ts:374-384 already scopes by channel.
Evidence: dispatcher.ts:1071-1079:
const shouldTeardownSession =
isSelf && voiceStore.getState().currentChannelId === payload.channel_id;
void livekitSession().then(({ handleParticipantLeft, leaveVoice }) => {
void handleParticipantLeft(payload.user_id); // <- payload.channel_id dropped
livekitE2EE.ts:1246-1253 (no channel parameter; acts on this._channelId):
async handleParticipantLeft(userId: number): Promise {
const departingKey = this._peerPublicKeys.get(userId);
const hadPeerKey = departingKey !== undefined;
this._peerPublicKeys.delete(userId);
this._peerOfferEpochs.delete(userId);
clearPeerVerification(userId);
const channelId = this._channelId ?? this.deps.getCurrentChannelId();
Audience proof, hub_broadcast.go:126-143: broadcastVoiceEventWithLeaver resolves h.channelReadAudience(ctx, channelID) — everyone with READ on the channel, regardless of voice membership.
Reordering proof (already documented in-tree): voice_leave goes through the async hub queue (hub_broadcast.go:160-168 h.broadcast <- bm), while voice_e2ee_announce is published straight into the recipient's send queue from the announcer's read pump (voice_e2ee.go:270-272 h.pubsub.Publish(VoiceTopic(channelID), ...)) — the same hazard livekitE2EE.ts:1268-1272 cites for OC-0213.
Suggested fix: Scope the E2EE notification to this client's own voice channel, mirroring removeVoiceUser and the shouldTeardownSession comparison already computed in the same handler. In dispatcher.ts, reuse the pre-leaveVoiceChannel store read: const sameChannel = voiceStore.getState().currentChannelId === payload.channel_id; (the value shouldTeardownSession already derives at :1071-1072), then at :1077 call it only when it matches — if (sameChannel) void handleParticipantLeft(payload.user_id); — leaving if (shouldTeardownSession) void leaveVoice(false); untouched. This keeps the one-argument call shape that dispatcher.test.ts:2728 asserts, and needs no change in livekitE2EE.ts. (Threading payload.channel_id into handleParticipantLeft and early-returning on mismatch against this._channelId ?? this.deps.getCurrentChannelId() is the alternative single-guard form, but it breaks that arity assertion and would require updating it to toHaveBeenCalledWith(7, 3).) The other caller, the ready-resync at dispatcher.ts:374-384, is already channel-scoped and unaffected.
OC-0312 — medium — Binding a push-to-talk key mid-call leaves PTT permanently dead — the pttOwnsMute latch is cleared by the store subscriber before the deferred setMuted(true) lands
Client/src/lib/ptt.ts:286 · found 2026-08-22 · hunt general-2026-08-22-b · lens client-state
The mid-call PTT arming block writes pttOwnsMute = true synchronously, but the mute it is describing (setMuted(true)) is applied inside a void import(...).then(...) callback that runs a microtask later. setPttGated(true) on the line above schedules voiceStore's notification microtask first, and the subscriber registered in initPtt (if (!s.localMuted) pttOwnsMute = false) still observes localMuted === false and resets the latch. When setMuted(true) finally runs, the mic is PTT-muted while pttOwnsMute is false, so every subsequent press takes the "never lift a mute the user asked for" early return.
Repro: User is in a voice call, unmuted, with no PTT key bound (localMuted=false, pttGated=false, isPttPollingLive()===true). They open Settings -> Keybinds and bind a PTT key, which calls updatePttKey(0x20).
Execution order:
updatePttKey -> await initPtt() registers pttStoreUnsubscribe = voiceStore.subscribe(s => { if (!s.localMuted) pttOwnsMute = false; }) (ptt.ts:163-165).
- ptt.ts:277 destructures
localMuted === false.
- ptt.ts:279
setPttGated(true) -> voiceStore.setState applies state synchronously and queues the notify microtask (store.ts:132-155). No prior notify was scheduled (initPtt writes no store state), so this microtask is fresh.
- ptt.ts:283-285
void import("./livekitSession").then(({setMuted}) => setMuted(true)) — the .then callback is queued after the dynamic-import promise resolves, i.e. strictly after the microtask queued in step 3.
- ptt.ts:286
pttOwnsMute = !localMuted -> true.
- Microtask from step 3 runs: subscriber sees
s.localMuted === false (setMuted has not run yet) -> pttOwnsMute = false.
- Microtask from step 4 runs:
setMuted(true) -> setLocalMuted(true) (livekitSession.ts:1597), mic muted.
End state: mic muted by PTT, pttOwnsMute === false.
User presses the PTT key: the ptt-state handler (ptt.ts:203) evaluates localMuted && !pttOwnsMute -> true && true -> returns with "PTT pressed — staying muted". The mic never opens. On release, ptt.ts:216 recomputes pttOwnsMute = !localMuted = !true = false, so the latch stays false forever: every press for the rest of the voice session is refused and the user is stuck muted with a non-functional PTT key, until they manually unmute via the voice widget.
Not test-locked: tests/unit/ptt.test.ts mocks @stores/voice.store with a stub setPttGated and a hand-driven subscribe, so the real store's microtask notification never fires during the OC-0162 arming tests (tests/unit/ptt.test.ts:512-608).
Evidence: ptt.ts:277-287
const { currentChannelId, pttGated, localMuted } = voiceStore.getState();
if (currentChannelId !== null && isPttPollingLive() && pttGated !== true) {
setPttGated(true);
// Muting is always safe (mirrors the ptt-state release handler
// below) — record whether this is what muted the mic so the next
// press may lift it (v006: never lift a mute the user asked for).
void import("./livekitSession")
.then(({ setMuted }) => setMuted(true))
.catch((e) => log.warn("Failed to gate mic after binding PTT key mid-call", e));
pttOwnsMute = !localMuted;
}
ptt.ts:163-165 (the clobbering subscriber)
pttStoreUnsubscribe = voiceStore.subscribe((s) => {
if (!s.localMuted) pttOwnsMute = false;
});
Contrast with the ptt-state release handler (ptt.ts:215-216), which is correct because setMuted runs synchronously before the latch write:
setMuted(true);
pttOwnsMute = !localMuted;
Suggested fix: Move the latch write into the callback so it lands after setMuted, mirroring the release handler at ptt.ts:215-216: replace lines 283-286 with void import("./livekitSession").then(({ setMuted }) => { setMuted(true); pttOwnsMute = !localMuted; }).catch((e) => log.warn("Failed to gate mic after binding PTT key mid-call", e)); — one edit in updatePttKey, no change to the subscriber or to any caller.
OC-0313 — medium — Pre-scoping per-user volume is copied into every server the user connects to — the legacy key is read through but never consumed
Client/src/lib/audioElements.ts:57 · found 2026-08-22 · hunt general-2026-08-22-b · lens client-state
getSavedUserVolume falls back to the unscoped userVolume_{id} key on a miss at the host-scoped key and persists the result under the scoped key, but unlike the identical migration in channel-mutes.ts it never removes the legacy key. Every subsequent brand-new host also misses its own scoped key, reads through to the same legacy value, and adopts it — so a volume (including a 0 = silenced) set for user id N on one server is silently applied to the unrelated user id N on every other server.
Repro: Pre-scoping install has owncord:settings:userVolume_7 = 0 (the user silenced person #7 on server A before host-scoping existed).
- Connect to server A. MainPage.ts:108 calls
setAudioVolumeHost("a.example.com"). getSavedUserVolume(7): scoped key userVolume_7:a.example.com misses -> legacy userVolume_7 returns 0 -> savePref("userVolume_7:a.example.com", 0) -> returns 0. The legacy key userVolume_7 is still present.
- Log out, connect to server B.
setAudioVolumeHost("b.example.com"). getSavedUserVolume(7): scoped key userVolume_7:b.example.com misses -> legacy userVolume_7 still returns 0 -> savePref("userVolume_7:b.example.com", 0) -> returns 0.
- In a voice call on server B,
handleTrackSubscribedAudio calls participant.setVolume(this.getEffectiveVolume(7)) = 0. User id 7 on server B — a different person, never touched by the user — is inaudible, with the volume slider showing 0 and no explanation.
The same happens on every further new host. channel-mutes.ts:106-111 fixes exactly this shape by consuming the legacy key (localStorage.removeItem(STORAGE_PREFIX + MUTED_KEY)) so the migration can only apply to the first host; audioElements.ts has no such removal.
Not test-locked: tests/unit/audio-elements.test.ts:256-273 only covers the case where the second host has its OWN explicit value (80), which short-circuits before the legacy branch.
Evidence: audioElements.ts:50-63
function getSavedUserVolume(userId: number): number {
const scopedKey = userVolumeKey(userId);
if (currentHost === null) return loadPref(scopedKey, 100);
const scoped = loadPref(scopedKey, VOLUME_NOT_SET);
if (scoped !== VOLUME_NOT_SET) return scoped;
const legacy = loadPref(userVolume_${userId}, VOLUME_NOT_SET);
if (legacy !== VOLUME_NOT_SET) {
savePref(scopedKey, legacy); // <-- legacy key is never removed
return legacy;
}
return loadPref(scopedKey, 100);
}
channel-mutes.ts:106-111 (the corrected sibling)
if (keyExists(MUTED_KEY)) {
const legacy = parseMutedIds(loadPref<unknown[]>(MUTED_KEY, []));
writeMuted(legacy);
localStorage.removeItem(STORAGE_PREFIX + MUTED_KEY);
return legacy;
}
Suggested fix: Consume the legacy key in the same branch, exactly as channel-mutes.ts does. In getSavedUserVolume, after savePref(scopedKey, legacy) at audioElements.ts:59, add localStorage.removeItem("owncord:settings:" + userVolume_${userId}) (use the same STORAGE_PREFIX constant the settings helpers use rather than a literal), so the migration can only ever apply to the first host connected post-upgrade and every later host falls through to the 100 default at line 62.
OC-0314 — medium — The server's "password changed but other sessions could NOT be revoked" partial-success warning is discarded by the client, which reports an unqualified success
Client/src/pages/MainPage.ts:500 · found 2026-08-22 · hunt general-2026-08-22-b · lens error-paths
PUT /users/me/password, POST /users/me/totp/confirm and DELETE /users/me/totp are deliberately built as partial-success endpoints: when the credential/2FA change commits but DeleteOtherSessions fails (twice — there is one bounded retry), the server returns HTTP 200 with {"warning": "...other sessions could not be revoked; revoke them from the sessions list", "sessions_revoked": N} instead of 204. service.ChangePasswordResult.RevokeFailed's own doc comment says this is "a partial success the caller must surface as a warning". The client throws the whole body away — api.changePassword / confirmTotp / disableTotp are all typed Promise<void> and every call site shows an unconditional green success toast — so the one signal the server designed for this case never reaches a human. (The admin SPA does render the analogous warnings array from the setup wizard at Server/admin/static/index.html:621, which is what the design intends.)
Repro: 1. A user believes their session was stolen and changes their password from Settings → Account.
2. UpdateUserPassword commits, then both DeleteOtherSessions attempts fail (SQLite writer busy / write-lock contention — the exact transient the one retry was added for). service.ChangePassword returns ChangePasswordResult{RevokeFailed: true}.
3. The handler returns HTTP 200 with the warning body instead of 204.
4. doFetch sees res.ok && status !== 204, parses the body, and hands it back as T = void; MainPage.ts:499 awaits it and MainPage.ts:500 shows "Password changed successfully" in green.
5. The attacker's session row is still in sessions and its bearer token still authenticates. The user has been told the operation fully succeeded and has no reason to open the sessions list, which is the only remaining way to revoke it. Identical outcome for enabling and disabling 2FA.
Evidence: Server/api/profile_handler.go:442
if res.RevokeFailed {
// Partial success: the password IS changed; only revoking the
// other sessions failed. ...
writeJSON(w, http.StatusOK, map[string]any{
"warning": "password changed, but other sessions could not be revoked; revoke them from the sessions list",
"sessions_revoked": res.SessionsRevoked,
})
Client/src/lib/api.ts:372
changePassword(currentPassword, newPassword, signal?): Promise {
return request("PUT", "/users/me/password",
{ old_password: currentPassword, new_password: newPassword }, signal);
},
Client/src/pages/MainPage.ts:498
await api.changePassword(oldPassword, newPassword);
showToast("Password changed successfully", "success");
Same shape at MainPage.ts:558-560 (confirmTotp -> "Two-factor authentication enabled") and MainPage.ts:569-571 (disableTotp -> "Two-factor authentication disabled"), against Server/api/totp_handler.go:359-370 and :460-471.
doFetch does receive the body — only 204 short-circuits:
Client/src/lib/api.ts:173
if (res.status === 204) { return undefined as T; }
return res.json() as Promise;
Suggested fix: Give the three partial-success endpoints a body type instead of void and surface it in one place. In Client/src/lib/api.ts, declare type PartialSuccess = { warning?: string; sessions_revoked?: number } | undefined; and change changePassword/confirmTotp/disableTotp to request<PartialSuccess>(...). Then in the three MainPage.ts handlers use the returned value for the toast, e.g. const res = await api.changePassword(old, new_); showToast(res?.warning ?? "Password changed successfully", res?.warning ? "warning" : "success"); (and the analogous two lines at :558 and :569). No server change is needed.
OC-0315 — medium — Replay-gate boundary compares a naive-UTC server timestamp parsed as LOCAL time against a local wall-clock anchor
Client/src/lib/dispatcher.ts:688 · found 2026-08-22 · hunt general-2026-08-22-b · lens ordering-boundary
payload.timestamp is the raw SQLite datetime('now') string ("2026-08-22 12:00:01" — UTC, no zone designator; Server/migrations/001_initial_schema.sql:81, passed through verbatim by service/message_crud.go:79). Date.parse treats it as LOCAL time, so the parsed epoch is off by the viewer's UTC offset. The codebase already has parseTimestamp() (components/message-list/formatting.ts:25-33) that exists solely to append the missing "Z"; this comparison bypasses it. The bias cancels once serverClockSkewMs has been sampled (line 765 uses the same biased parse), but it is 0 until the first accepted live message — so the very first reconnect of a session is decided by the viewer's timezone instead of by the timestamp.
Repro: Cold-skew case (serverClockSkewMs still 0 — no chat_message received since login, i.e. a quiet channel).
East of UTC, e.g. viewer at UTC+2: socket blips and reconnects at wall time H; 1 s later a peer posts a genuinely LIVE message. Date.parse(ts) = T - 2h, so T - 2h < H - 0 is true → isReplayFrame = true → notifyIncomingMessage is skipped (no desktop notification, no sound, no taskbar flash). Worse, line 700-702 computes isMention = ... && !(mentions_here && isReplayFrame), so a live @here that names the viewer raises no mention badge at all — and the reconnect tier sends no follow-up ready to correct it (OC-0271), so the badge is lost permanently.
West of UTC, e.g. viewer at UTC-5: Date.parse(ts) = T + 5h, so the test is false for every frame → the entire replayed burst is classified live and fires one desktop notification + sound per already-seen message, which is exactly what the gate was added to prevent.
Not caught by tests: every timestamp in tests/unit/dispatcher.test.ts (lines 515, 541, 597, 622, 672, 779, 810…) is an ISO Z string, a form the server never emits.
Evidence: 685: const isReplayFrame =
686: lastReconnectHandshakeAt !== null &&
687: Date.now() - lastReconnectHandshakeAt < REPLAY_GATE_WINDOW_MS &&
688: Date.parse(payload.timestamp) < lastReconnectHandshakeAt - serverClockSkewMs;
...
765: serverClockSkewMs = Date.now() - Date.parse(payload.timestamp);
(the helper that exists for exactly this, formatting.ts:31-34:)
const date = !raw.endsWith("Z") && !raw.includes("+") && !/T\d{2}:\d{2}:\d{2}[+-]/.test(raw)
? new Date(raw.replace(" ", "T") + "Z") : new Date(raw);
Suggested fix: Route both parses through the existing UTC-normalizing helper instead of bare Date.parse — e.g. import { parseTimestamp } from "@components/message-list/formatting" (or lift it into @lib) and use parseTimestamp(payload.timestamp).getTime() at dispatcher.ts:688 and :765. One shared helper at both sites, no per-caller guards.
OC-0316 — medium — WS resume re-syncs peer ECDH keys but never the room key — a rotation that lands during the outage strands a non-key-holder on a dead key while the UI still says "Secured"
Server/ws/hub.go:665 · found 2026-08-22 · hunt general-2026-08-22-b · lens flow-reconnect
registerNow's resume-time E2EE resync (OC-0276) only pushes other participants' stored ECDH public keys to the resuming client. The room key itself travels the other way, as a targeted unsequenced voice_e2ee_offer, and one sent while the socket was down is dropped outright. Nothing on either side re-runs the exchange after the resume: the server never re-offers, and the client's only re-announce paths (setupKeyExchange, reannounceForReconnect) are both driven by the LiveKit room, not by the WebSocket, so a pure WS blip leaves a non-key-holder holding the pre-rotation key with no signal and no retry.
Repro: Users A (lower user id, key holder) and B are in a voice call; both LiveKit sessions are healthy. B's WebSocket drops (WiFi blip / proxy restart) but its LiveKit room stays up — nothing tears voice down on a socket drop alone (dispatcher.ts READY comment, livekitSession.ts). The server has not yet observed B's TCP close, so B's old *Client is still in h.clients. While B is offline a third participant C leaves (or A's 5-minute KEY_ROTATION_INTERVAL_MS timer fires, livekitE2EE.ts:109): A rotates the room key and sends a voice_e2ee_offer for B. sendToUserIfInVoiceChannel queues it onto B's dead client and it is lost. B reconnects with last_seq > 0; handleReconnect replays the sequenced voice_state/voice_leave frames, registerNow transfers B's voice state and calls sendVoicePeerKeys — so B's roster and peer-key map are correct — but B's keyProvider still holds the pre-rotation key. From that moment A and B cannot decrypt each other's frames: both hear silence while VoiceWidget still shows "Secured", and the only recovery is A's next 5-minute periodic rotation.
Evidence: Server/ws/hub.go:664-666 (registerNow tail):
if voiceChID := c.getVoiceChID(); voiceChID != 0 {
h.sendVoicePeerKeys(c, voiceChID)
}
sendVoicePeerKeys (Server/ws/voice_e2ee.go:344-350) only sends buildVoiceE2EEAnnounce(uid, pubKey, sig) for every other participant — no room-key material.
The offer path drops silently while the socket is down (Server/ws/voice_e2ee.go:239-259):
target, ok := h.clients[targetUserID]
if !ok { slog.Debug("e2ee: key offer dropped, target not connected", ...); return }
...
target.sendMsg(msg)
(and while the dead old *Client is still registered, sendMsg queues into a send buffer that registerNow's old.closeSend() then discards).
Client side, the only two re-announce entry points are LiveKit-driven:
Client/src/lib/livekitE2EE.ts:159 setupKeyExchange <- called only from livekitSession.ts:1103 (connectAndSetup)
Client/src/lib/livekitE2EE.ts:346 reannounceForReconnect <- called only from livekitSession.ts:564 (attemptAutoReconnect)
Neither is reachable from a WS resume: dispatcher.ts's AUTH_OK handler (lines 270-290) does exactly setAuth() + one channel_focus send, and the READY handler's E2EE work (OC-0201, dispatcher.ts:360-384) runs only on the full-resync tier, which a successful replay resume never takes.
reannounceForReconnect's own comment states the assumption that is unmet here: "the key holder will send a fresh offer if the key was rotated during our absence" (livekitE2EE.ts:343-344) — true only because that path re-announces; the WS-resume path does not.
Suggested fix: One server-side addition at registerNow's resync call site (Server/ws/hub.go:664-666) — do not put it inside sendVoicePeerKeys, since voice_join.go:531 shares that function and the joiner's own announce already comes from its client there:
This needs no client change: handleAnnounceInner's dedup branch (livekitE2EE.ts:~812, "duplicate announce — will re-send offer if key holder") deliberately falls through to the wrap-and-offer branch on an identical key, so the holder re-offers the live room key. The announce is not blocked by _retiredPeerKeys (that set holds only keys a peer has been moved OFF of, never the live one) and re-runs verifyPeerAnnounce exactly as reannounceForReconnect's announce already does. Guard it on c.lastSeq > 0 if you want it strictly on the resume path.
OC-0317 — medium — updateDmLastMessage writes a regressing lastMessageId on the replay branch, defeating its own OC-0242 double-count guard
Client/src/stores/dm.store.ts:153 · found 2026-08-22 · hunt general-2026-08-22-b · lens hotspot-client-tauri-client-src-components
The replay branch suppresses the unread/mention increment but still writes lastMessageId: messageId unconditionally. When the redelivered id is lower than the stored watermark, the watermark is rolled backwards, so the very next frame in the same replay burst no longer looks like a replay and is counted as new. Its sibling updateDmLastMessagePreview (lines 186-190) documents this exact hazard (OC-0301) and returns prev instead — updateDmLastMessage never got the same treatment.
Repro: DM channel 5. Two messages (ids 101 then 102) are delivered by the server in the registerNow→buildReady window, so ready lands with unreadCount=2 / lastMessageId=102, and both frames are then drained from the queue as chat_message (dispatcher.ts:740 calls updateDmLastMessage for each, since the DM is neither own-message nor active).
- frame 101: isReplay = (101 <= 102) = true → unreadCount stays 2, but lastMessageId is overwritten with 101.
- frame 102: isReplay = (102 <= 101) = false → unreadCount = 3, and mentionCount = +1 if the message mentioned the reader.
The DM sidebar badge shows 3 unread (and a phantom mention) for 2 messages, and it survives until the next full
ready. Nothing in tests/unit/dm-store.test.ts asserts lastMessageId after a stale call, so the behavior is not locked.
Evidence: const isReplay = updated.lastMessageId !== null && messageId <= updated.lastMessageId;
return { channels: [ { ...updated,
lastMessageId: messageId, // <-- regresses the watermark on a replay
lastMessage: content,
lastMessageAt: timestamp,
unreadCount: isReplay ? updated.unreadCount : updated.unreadCount + 1,
mentionCount: isMention && !isReplay ? updated.mentionCount + 1 : updated.mentionCount,
}, ...rest ] };
Suggested fix: Mirror the sibling: in updateDmLastMessage's setState, replace the isReplay ternaries with an early if (isReplay) return prev; right after the isReplay computation (dm.store.ts:149). One guard in the shared function; the equal-id case is the same message ready already previewed, so nothing visible is lost.
OC-0318 — medium — Zip install validates plugin.json while the on-disk loader prefers plugin.toml — a plugin's capabilities/commands/entrypoint change out from under the admin at the next restart
Server/plugin/registry.go:422 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-2
Two sources of truth for one plugin directory, read with opposite precedence by the two paths that consume it. installZipStagedManifest reads only plugin.json from the staged zip, and that is the manifest that is validated, shown to the admin, persisted to plugins.manifest_json, and used to activate the instance. scanPluginDirectory — the path that runs on every server start — prefers plugin.toml and only falls back to plugin.json when the TOML file is absent. A zip may contain both files (installZipExtract rejects only symlinks and path escapes, not extra regular files), so the manifest that governs the plugin after a restart is one that was never examined at install time. The manifest is the per-plugin ACL (manifest.go:64-67, errors.go:18-22: "the manifest — not the guest module — is the authority ... so an admin can see the full command surface before enabling the plugin"), so this defeats exactly the review it exists for.
Repro: Server built with -tags wazero (the build where plugins actually execute and where TOML is parsed). Upload a zip through POST /api/v1/admin/plugins/install containing plugin.json with "permissions": ["commands"], "commands": [{"name":"hello"}], "entrypoint":"hello.wasm" — plus a plugin.toml at the same root declaring permissions = ["commands","http","storage","ui"], extra [[commands]] entries, and entrypoint = "other.wasm". Install succeeds; installZipStagedManifest parses only the JSON, so the admin list, the stored manifest_json, and the immediately-activated instance all show the narrow JSON surface. Restart the server: LoadAll → scanPluginDirectory (loader.go:64) picks plugin.toml, installFromDisk upserts that manifest, and activateAll brings the plugin up with the broader capability set, the undeclared-at-review commands, and a different .wasm entrypoint — with no new admin action and no log line noting that the effective manifest changed. The same mechanism bites non-maliciously: an author who ships both files and later edits only plugin.json sees the stale TOML silently win after every restart while the freshly installed process used the JSON.
Evidence: registry.go:421-427 (install path)
manifestPath := filepath.Join(stageAbs, "plugin.json")
raw, err := os.ReadFile(manifestPath)
if err != nil { return nil, fmt.Errorf("plugin zip: missing plugin.json at root: %w", err) }
manifest, err := ParseManifest(raw)
loader.go:63-86 (load path)
// Prefer plugin.toml (wazero build) over plugin.json.
manifest, ok, tomlErr := tryLoadPluginTOML(pluginDir)
...
if !ok { /* only now read plugin.json */ }
registry.go:286-296 — the staged tree (including any plugin.toml) is promoted verbatim into finalDir and registered with the JSON manifest.
grep -rn "plugin.toml" Server/ --include=*.go matches only manifest_toml.go and the loader comment: nothing in the install path ever looks at it.
Suggested fix: Make both paths resolve the manifest through one function instead of guarding each caller. Extract loader.go:62-86's precedence into a shared helper and call it from the install path too:
Then replace registry.go:422-427 with manifest, err := loadManifestFromDir(stageAbs) (keeping the existing "missing plugin.json at root" wrapping for os.IsNotExist) and have scanPluginDirectory call the same helper. The manifest the admin's install validates is then byte-for-byte the one the next restart loads, in both build tags. If keeping JSON-only at install is preferred, the equally small alternative is to reject the ambiguity at the single install site — after extraction, if _, err := os.Stat(filepath.Join(stageAbs, "plugin.toml")); err == nil { return nil, fmt.Errorf("plugin zip: must not contain both plugin.json and plugin.toml") } — but the shared-helper version also fixes the plain on-disk case where an author edits only one of the two files.
OC-0319 — medium — "Large Font" accessibility toggle is inert — the inline --font-size written on <html> outranks the .large-font class rule
Client/src/styles/app.css:5248 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-1
.large-font { --font-size: 18px } targets document.documentElement, but that same element permanently carries an inline --font-size written by applyStoredAppearance() and buildAppearanceTab(). An inline declaration beats an author class rule without !important on the same element, so the class can never take effect. The sibling .high-contrast rule six lines above (app.css:5239-5243) carries a comment describing exactly this hazard and was given !important; .reduced-motion (app.css:5228) also uses !important. .large-font is the one that was left out.
Repro: 1. Fresh install; never touch the Appearance > Font Size slider. At startup main.ts:102 calls applyStoredAppearance(), which writes inline --font-size: 16px on <html> (loadPref fallback 16).
2. Open Settings > Accessibility and toggle "Large Font" ON. AccessibilityTab.ts:65 adds the large-font class to <html> and the pref is persisted.
3. Computed --font-size on <html> is still 16px — the inline declaration wins over .large-font's 18px. body's font-size (base.css:22) is unchanged, so no text anywhere grows.
4. The toggle renders as ON forever after, and survives restart, while having zero visual effect. The only way to change text size remains the Appearance slider.
Evidence: app.css:5247-5250
/* Accessibility: large font */
.large-font {
--font-size: 18px;
}
appearance.ts:37-40 (runs unconditionally at startup, main.ts:102)
document.documentElement.style.setProperty("--font-size", ${loadPref<number>("fontSize", 16)}px);
appearance.ts:53
document.documentElement.classList.toggle("large-font", loadPref("largeFont", false));
AppearanceTab.ts:232
document.documentElement.style.setProperty("--font-size", ${currentFontSize}px);
AppearanceTab.ts:100 (slider input)
document.documentElement.style.setProperty("--font-size", ${size}px);
AccessibilityTab.ts:60-66
key: "largeFont", ... sideEffect: (nowOn) => { document.documentElement.classList.toggle("large-font", nowOn); }
base.css:22
font-size: var(--font-size, 14px); /* on body, inherits html's inline value */
Contrast — app.css:5234-5243 (same file, six lines earlier):
/* ... an inline declaration beats a plain class rule on the same element,
so these overrides must be !important ... */
.high-contrast, .high-contrast body { --text-normal: #ffffff !important; ... }
Nothing ever clears the inline property: the only removeProperty() calls are helpers.ts:133 (THEME_KEYS only — its own comment at line 131 says --font-size is deliberately excluded) and themes.ts:57 (document.body, not documentElement).
Suggested fix: One character class of change in the shared rule: .large-font { --font-size: 18px !important; } (app.css:5248-5250), matching the .high-contrast/.reduced-motion precedent six lines above. Unlike .high-contrast it need not also target body — --font-size is only ever written inline on documentElement, never on body. Note the resulting semantics: while Large Font is on it pins 18px and the Appearance slider is overridden until the toggle is turned off, which is the intended 'accessibility override wins' behavior.
OC-0320 — medium — Server self-update is architecture-blind: an ARM64 Linux server downloads, verifies and commits the amd64 binary, then cannot restart
Server/updater/download.go:256 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-1
serverDownloadAssetName keys only on runtime.GOOS and hardcodes the amd64 tarball; runtime.GOARCH is never consulted anywhere in the updater package. The signature/manifest/checksum chain all validate (they correctly describe the amd64 artifact), so nothing fails closed, and the sibling downloader Server/ws/livekit_download.go:49 shows the intended arch gate that is missing here.
Repro: On a linux/arm64 server built from source (docs/contributing.md:13 lists Linux ARM64 server as supported; README.md:48 says no ARM64 binary is published, so source build is the documented path), the operator clicks Apply Update in the admin panel:
- fetchLatestRelease sets downloadURL to the release's chatserver-linux-amd64.tar.gz; hasRequiredServerAssetsFor("linux", ...) (updater.go:299) returns true, so UpdateInfo.UpdateAvailable is true and handleApplyUpdate (Server/admin/update_handlers.go:84-95) proceeds.
- DownloadAndVerify succeeds end to end: parseChecksumFileAny matches the bare
chatserver-linux-amd64.tar.gz checksum line, VerifyReleaseManifest binds that same asset name, the minisign signature on the manifest verifies, and the tarball SHA256 matches. Nothing rejects the wrong architecture.
- extractChatserverFromTarGz writes the amd64 ELF to exePath+".new" and chmods it 0755.
- applyStagedUpdate (update_handlers.go:198-212) renames the working arm64 binary to exePath+".old" and commits the amd64 file to exePath; os.SameFile passes because it is the verified file.
- performRestartHandoff (Server/restart.go:183) calls SpawnDetached(exePath, os.Args[1:]), which fails with
fork/exec ...: exec format error; it logs "spawning the replacement process FAILED — manual restart required" and the process exits.
Result: server is down, exePath holds an unrunnable foreign-arch binary, and recovery requires manually restoring chatserver.old on the host. Expected: the update check should refuse (RequiredAssetsPresent=false / an explicit unsupported-architecture error) the way livekitAssetName does. No test locks the current behavior — updater_test.go:462 asserts only the GOOS->name mapping.
Evidence: Server/updater/download.go:252-260
func serverDownloadAssetName(goos string) string {
switch goos {
case "windows":
return windowsServerBinary
case "linux":
return linuxServerArchive // const = "chatserver-linux-amd64.tar.gz"
default:
return ""
Server/updater/updater.go:256
wantBinary := serverDownloadAssetName(runtime.GOOS) // no GOARCH
Server/updater/verify.go:48-56 checksumEntryNamesForGOOS(goos) likewise hardcodes
"linux/chatserver-linux-amd64.tar.gz", "chatserver-linux-amd64.tar.gz"
Contrast, Server/ws/livekit_download.go:49-57 (fails closed on arch):
func livekitAssetName(version, goos, goarch string) (string, error) {
switch goarch {
case "amd64", "arm64": arch = goarch
case "arm": arch = "armv7"
default: return "", fmt.Errorf("livekit auto-download does not support architecture %s ...")
Suggested fix: Give serverDownloadAssetName a goarch parameter and fail closed on anything the release does not actually ship, so the empty downloadURL makes hasRequiredServerAssetsFor return false and the apply endpoint answers MISSING_ASSETS instead of installing a foreign-arch binary. In Server/updater/download.go:252: func serverDownloadAssetName(goos, goarch string) string { if goarch != "amd64" { return "" }; switch goos { ... } }, and pass runtime.GOARCH at Server/updater/updater.go:256. That single gate is sufficient — no other call site chooses the asset — though mirroring it in checksumEntryNamesForGOOS (Server/updater/verify.go:48) keeps the two name tables consistent when an arm64 tarball is eventually published.
OC-0321 — medium — LoadOrGenerateTOTPKey treats ANY totp.key read error as "no key yet" and overwrites the file with a fresh key, permanently orphaning every stored TOTP secret
Server/auth/totp_encrypt.go:45 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-3
Step 2 only handles the success branch (if data, err := os.ReadFile(keyPath); err == nil). Every non-nil read error — EACCES, EIO, EISDIR, ELOOP, EMFILE, or a dangling symlink's ENOENT — falls through to step 3, which generates a brand-new random key and writes it with os.WriteFile(keyPath, ..., 0o600) (O_WRONLY|O_CREATE|O_TRUNC). Wherever the write then succeeds, the original key is destroyed. This directly contradicts the invariant the file's own siblings enforce and that TestLoadOrGenerateTOTPKey_RejectsBadKeyFile pins for the corrupt-content cases ("a corrupt totp.key is a hard error rather than a silent regeneration (which would orphan every stored secret)" — Server/auth/totp_encrypt_test.go:212-249, which asserts the file is not rewritten). Only the invalid-hex and wrong-length branches fail closed; the read-error branch fails open.
Repro: On a running server with 2FA users: chmod 200 data/totp.key (write-only — an ACL/umask/restore artifact; the same happens on a transient EIO, EMFILE, or a symlinked key file whose target is temporarily gone). Restart the server. os.ReadFile returns EACCES, the read branch is skipped, a fresh 32-byte key is generated and os.WriteFile truncates data/totp.key with it (the file is writable, so the write succeeds). The server logs "auto-generated TOTP encryption key and saved to disk" and starts normally. Now every users.totp_secret in the DB is AES-GCM ciphertext under the destroyed key: totpChallengeSecret (Server/api/totp_handler.go:160) calls DecryptTOTPSecret, GCM authentication fails, the fail-closed branch (totp_encrypt.go:139-149) returns an error and POST /api/v1/auth/verify-totp answers 500 INTERNAL_ERROR for every 2FA account, forever. The accounts cannot re-enroll either, because login never gets past the second factor. The old key is gone, so there is no recovery. The correct behaviour is the one the wrong-hex/wrong-length branches already implement: return an error for any error other than os.IsNotExist(err).
Evidence: Server/auth/totp_encrypt.go:45-55
keyPath := filepath.Join(dataDir, "totp.key")
if data, err := os.ReadFile(keyPath); err == nil {
...
return key, nil
}
Suggested fix: Distinguish "absent" from "failed" at the single read site: data, err := os.ReadFile(keyPath); if err == nil { ...existing decode/length checks... }; if !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("reading totp.key: %w", err) } before falling through to generation. routerTOTPKey already panics on a non-nil error with a configured DataDir, so this fails the boot closed exactly like the corrupt-content branches.
OC-0322 — low — isValidHost accepts an underscore in a hostname that every Rust proxy rejects, so the client saves and accepts a server it can never reach
Client/src/lib/hostValidation.ts:33 · found 2026-08-22 · hunt general-2026-08-22-b · lens tauri-rust
The DNS-name branch uses [\w.-]+, and JS \w is [A-Za-z0-9_] — so a host containing _ passes. Both http_proxy::validate_remote_host and livekit_proxy::validate_remote_host allow only is_ascii_alphanumeric() || '.' | '-' | ':' | '[' | ']' and reject _. Since every REST call routes through ensureHttpProxy (api.ts:88), an underscore host is accepted by the Add Server modal and by api.setConfig, then fails 100% of REST traffic. The file's own header comment and ServerPanel.ts:310-313 both state the invariant that this validator mirrors the Rust one ("an address accepted here is also accepted by the actual connection path, and vice versa").
Repro: Connect page -> "Add Server" -> address my_server.lan:8443. ServerPanel.ts:314 isValidHost(addr) returns true (JS \w matches _), so the profile is saved. The connect page then health-checks it: api.getHealth("my_server.lan:8443") -> ensureHttpProxy(host) -> invoke("start_http_proxy", {remoteHost}) -> http_proxy.rs:101 validate_remote_host -> Err("remote_host contains unexpected characters"). Every REST call fails identically, so login is impossible and the profile shows permanently unreachable; start_livekit_proxy rejects the same host, so voice is dead too. The WS proxy has no charset check, so wss://my_server.lan/api/v1/ws would have connected — the client accepts an address that only one of its three transports can use.
Evidence: hostValidation.ts:33 return /^[\w.-]+(:\d+)?$/.test(host); // \w includes '_'
http_proxy.rs:83-88
if !remote_host
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'))
{
return Err("remote_host contains unexpected characters".into());
}
livekit_proxy.rs:110-116 (identical charset, same rejection)
api.ts:87-89
async function baseUrl(): Promise {
return ${await ensureHttpProxy(config.host)}/api/v1;
}
commands.rs tests pin the Rust side as deliberate:
("underscore", "chat_example.com".into()), // expected to be rejected
tests/unit/host-validation.test.ts has no underscore case, so nothing locks the TS behavior.
Suggested fix: One character-class change in the shared validator, hostValidation.ts:33: replace \w with an explicit ASCII class so the DNS branch matches the Rust charset — return /^[A-Za-z0-9.-]+(:\d+)?$/.test(host);. Add an underscore rejection case to tests/unit/host-validation.test.ts mirroring commands.rs:364 so the two validators stay pinned together.
OC-0323 — low — mark_read/channel_focus zeroes mention_count from a stale latest-message snapshot, permanently destroying a mention raised during the round trip
Server/service/channel.go:289 · found 2026-08-22 · hunt general-2026-08-22-b · lens concurrency
HandleChannelFocus reads latestID on the reader pool and then, two round trips later, issues UpdateReadState(user, channel, latestID) — whose SQL unconditionally sets mention_count = 0 with no message-id guard. A mention raised for a message NEWER than the snapshotted latestID (by SendMessage's background applyMentionCounts goroutine) is wiped by that blind write. The sibling writer IncrementMentionCounts was explicitly hardened against the mirror-image race with an atomic WHERE read_states.last_message_id < ? guard; the clearing side has no equivalent, and nothing ever recomputes mention_count (GetChannelUnreadCounts reads the stored column), so the badge is lost forever.
Repro: User U has read state (last_message_id=90, mention_count=0) in channel C, whose newest message id is 100. G1 = U's WS readPump handling mark_read for C (right-click → "Mark as Read", or one frame of the client's markAllRead burst — U is NOT looking at C). G2 = another user's send of "@U ping" in C.
- G1: GetLatestMessageID(C) -> 100.
- G1: GetReadState(U,C) -> (90, 0, found) — the skip branch does not fire.
- G2: CreateMessageWithMentions commits message id=101 mentioning U; SendMessage returns and
s.bg spawns applyMentionCounts.
- G2 (background goroutine): IncrementMentionCounts(C, 101, [U]) -> guard
90 < 101 passes -> read_states.mention_count becomes 1.
- G1: UpdateReadState(U, C, 100) -> last_message_id=100, mention_count=0.
Result: U's row is (last_message_id=100, mention_count=0) while message 101 mentions U and is unread. The channel still shows an unread count (101 > 100) but the @-mention badge is gone, and no code path ever re-derives it — IncrementMentionCounts for 101 has already run and DecrementMentionCounts only ever subtracts. The same unguarded wipe also runs from Server/service/message_crud.go:71 (SendMessage advancing the author's own read state to msgID), where a concurrent mention from another user with a higher id is destroyed identically.
Evidence: Server/service/channel.go:281-291
latestID, err := s.st.GetLatestMessageID(ctx, channelID)
if err == nil {
lastRead, mentions, found, rsErr := s.st.GetReadState(ctx, userID, channelID)
if rsErr == nil && found && lastRead == latestID && mentions == 0 { ... return ch, nil }
if wErr := s.st.UpdateReadState(ctx, userID, channelID, latestID); wErr != nil { ... }
Server/db/queries/sqlite/messages.sql:32-39 (UpdateReadState)
ON CONFLICT(user_id, channel_id) DO UPDATE SET
last_message_id = excluded.last_message_id,
mention_count = 0; -- no msgID guard
Server/db/mention_queries.go:229-231 (IncrementMentionCounts — the guard that exists on the other side)
ON CONFLICT(user_id, channel_id) DO UPDATE SET
mention_count = mention_count + 1
WHERE read_states.last_message_id < ?
Server/service/message_crud.go:104-106 — the increment runs on its own goroutine:
s.bg(func() { s.applyMentionCounts(context.WithoutCancel(ctx), channelID, msgID, authorID, mentions, isDM, participantIDs) })
Server/service/message.go:173 — bg: func(fn func()) { go fn() }
Suggested fix: Make the read-and-clear atomic in one writer statement instead of passing a snapshot: replace the GetLatestMessageID/UpdateReadState pair with a single upsert that computes the id inline, e.g. INSERT INTO read_states (user_id, channel_id, last_message_id, mention_count) VALUES (?, ?, (SELECT COALESCE(MAX(id),0) FROM messages WHERE channel_id = ? AND deleted = 0), 0) ON CONFLICT(user_id, channel_id) DO UPDATE SET last_message_id = excluded.last_message_id, mention_count = 0. Any message committed before it is covered by last_message_id; any committed after finds last_message_id < msgID false only when it is genuinely covered, so IncrementMentionCounts' existing guard closes the other direction. One shared query change; HandleChannelFocus keeps its GetReadState skip-check, and message_crud.go:72 (which already has the exact msgID) can keep calling the existing signature.
OC-0324 — low — Login's per-username lockout key uses Go Unicode case-folding while the account lookup uses SQLite COLLATE NOCASE — two distinct accounts share one lockout bucket
Server/api/auth_handler.go:471 · found 2026-08-22 · hunt general-2026-08-22-b · lens state-desync
Identity is normalized by two different rules that disagree. loginAuthenticate derives the per-username failure/lockout key with strings.ToLower (full Unicode folding), while the account it is protecting is resolved with WHERE username = ? COLLATE NOCASE (Server/db/queries/sqlite/users.sql:5) and stored under username TEXT NOT NULL UNIQUE COLLATE NOCASE (Server/migrations/001_initial_schema.sql:24) — SQLite's NOCASE folds ASCII A–Z only. auth.ValidateUsername (Server/auth/helpers.go:19-47) rejects only control and Cf runes, and registration applies no case normalization (auth_handler.go:298 req.Username = strings.TrimSpace(service.SanitizeText(req.Username))), so two accounts that differ only by a non-ASCII letter's case are two independent rows — yet they collapse to a single login_user_fail: / login_user_lock: key. Failed logins against one account therefore lock the other out of its own account. The disagreement is one-directional (anything equal under NOCASE is also equal under ToLower), so this is collateral over-locking, not an auth bypass.
Repro: 1. Register "ärger" (password P1) and "Ärger" (password P2). Both succeed: the UNIQUE COLLATE NOCASE index does not fold "Ä"/"ä", so two separate users rows exist. 2. POST /api/v1/auth/login 10 times with username "ärger" and a wrong password. At auth_handler.go:471 unameKey = "ärger"; :515 records each attempt under "login_user_fail:ärger"; on the 10th, :541-542 calls limiter.Lockout("login_user_lock:ärger", loginUserLockoutDuration = 15m) (Server/api/constants.go:119,126). 3. The owner of "Ärger" now POSTs /auth/login with the CORRECT password P2. Line 471 computes strings.ToLower("Ärger") == "ärger", so line 473's IsLockedOut("login_user_lock:ärger") is true and the request is answered 429 RATE_LIMITED "account temporarily locked due to too many failed attempts" for the full 15 minutes — even though that account has had zero failed attempts. The same collision also means an attacker can spend attempts against a username string that matches no account at all (the constant-time path at :525-535 still records the failure for a non-existent user) and have it trip a real account's lockout.
Evidence: Server/api/auth_handler.go:471-473,501,515,541-542:
unameKey := strings.ToLower(req.Username)
userLockKey := "login_user_lock:" + unameKey
if limiter.IsLockedOut(userLockKey) { ... 429 ... }
...
userFailKey := "login_user_fail:" + unameKey
... limiter.Allow(userFailKey, loginUserFailureThreshold+1, loginUserFailureWindow) ...
if !limiter.Check(userFailKey, ...) { limiter.Lockout(r.Context(), userLockKey, loginUserLockoutDuration) }
Server/db/queries/sqlite/users.sql:5:
FROM users WHERE username = ? COLLATE NOCASE;
Server/migrations/001_initial_schema.sql:24:
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
The comment above line 471 states the intent explicitly — "canonicalize the username the same way GetUserByUsername does (COLLATE NOCASE)" — which strings.ToLower does not do for non-ASCII.
Suggested fix: One-token change at Server/api/auth_handler.go:471 — use the fold the repo already established for this exact hazard: unameKey := db.LowerASCII(req.Username) instead of strings.ToLower(req.Username) (package api already imports Server/db). That makes the lockout key agree exactly with COLLATE NOCASE, keeps the intended admin/Admin/ADMIN bucket sharing, and stops distinct non-ASCII-case accounts from colliding. Drop the now-unused strings import only if nothing else in the file uses it (it does — leave it).
OC-0325 — low — Search overlay parses the server's naive-UTC timestamp as local time, so a search hit shows a different clock time than the same message in the message list
Client/src/components/SearchOverlay.ts:57 · found 2026-08-22 · hunt general-2026-08-22-b · lens ordering-boundary
r.timestamp is messages.timestamp verbatim (db/models.go:168, populated by the raw column in SearchMessages), i.e. "2026-08-22 15:00:00" — UTC with no zone designator. new Date(ts) interprets that as local wall-clock, shifting the rendered time by the viewer's UTC offset. The message list renders the identical string through parseTimestamp() (message-list/formatting.ts:25), which appends "Z" — so the two surfaces disagree about the same message.
Repro: Viewer in UTC-5. A message is sent at 10:00 local (15:00 UTC); the server stores "2026-08-22 15:00:00". The message list shows "10:00 AM". Search for that message in the Search overlay: new Date("2026-08-22 15:00:00") parses as 15:00 local → the result row is labelled "Aug 22 03:00 PM". Clicking through jumps to a message the list says was sent at 10:00 AM.
Evidence: 55: function formatTimestamp(ts: string): string {
56: try {
57: const d = new Date(ts);
58: return (
59: d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) +
60: " " +
61: d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })
Suggested fix: Use the shared helper in formatTimestamp: const d = parseTimestamp(ts); (import { parseTimestamp } from "@components/message-list/formatting") in place of new Date(ts) at SearchOverlay.ts:57.
OC-0326 — low — Pinned-messages list parses the naive-UTC timestamp as local time, mislabelling the date by a full day near the UTC day boundary
Client/src/components/PinnedMessages.ts:27 · found 2026-08-22 · hunt general-2026-08-22-b · lens ordering-boundary
msg.timestamp comes from GetPinnedMessages → MessageAPIResponse.Timestamp, the raw SQLite "YYYY-MM-DD HH:MM:SS" UTC string. new Date(iso) reads it as local wall-clock rather than UTC (the project's parseTimestamp() helper exists for exactly this), and because only the date is rendered, any message whose UTC time and local time fall on different calendar days is labelled with the wrong day.
Repro: Viewer in UTC-5. A message is sent at 20:00 local on Aug 21, i.e. 01:00 UTC on Aug 22; the server stores "2026-08-22 01:00:00". The message list (via parseTimestamp) groups it under Aug 21. Open the pinned-messages panel: new Date("2026-08-22 01:00:00") is Aug 22 01:00 local, so the pin is labelled "Aug 22, 2026" — one day later than the same message's day divider in the channel.
Evidence: 26:function formatPinTime(iso: string): string {
27: const d = new Date(iso);
28: if (isNaN(d.getTime())) return iso;
29: return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
Suggested fix: Replace const d = new Date(iso); at PinnedMessages.ts:27 with const d = parseTimestamp(iso); (import { parseTimestamp } from "@components/message-list/formatting"); the existing isNaN guard still covers a malformed string.
OC-0327 — low — Moderator server-mute/deafen also mutes the target's screen-share audio at the SFU, contradicting both its own contract and the client's mute policy
Server/ws/livekit.go:205 · found 2026-08-22 · hunt general-2026-08-22-b · lens flow-voice
MuteParticipantAudio documents itself as muting "every microphone track the participant publishes", but its filter is t.Type != livekit.TrackType_AUDIO — which admits screen-share audio as well as the microphone. livekit.TrackInfo carries a Source field (TrackSource_MICROPHONE = 2 vs TrackSource_SCREEN_SHARE_AUDIO = 4) that is never consulted, and the client publishes screen-share audio under exactly that source (Client/src/lib/screenShare.ts:373, source: isVideo ? Track.Source.ScreenShare : Track.Source.ScreenShareAudio). The client's own mute/deafen implementation states and enforces the opposite rule in two places — audioElements.ts:121-127 (publication.source !== Track.Source.ScreenShareAudio before refusing to attach while deafened) and audioElements.ts:216 (if (publication.source === Track.Source.ScreenShareAudio) continue;), both commented "Screen-share/stream audio is exempt: muting or deafening yourself gates voices, not the content someone is streaming". So the server's moderator mute silently reaches past the voice channel it is scoped to and kills a live screen share's audio for every viewer.
Repro: User B joins voice channel #10 and starts a screen share with audio (screenShare.ts publishes the tab/system audio track as Track.Source.ScreenShareAudio). Users A and C are listening to the shared content. A moderator sends {"type":"voice_mod_mute","payload":{"channel_id":10,"user_id":B,"muted":true}} intending to silence B's microphone. Server: ApplyVoiceServerMute sets server_muted=1/muted=1, then MuteParticipantAudio iterates p.Tracks and calls MutePublishedTrack on BOTH B's microphone SID and B's screen-share-audio SID (both are TrackType_AUDIO). B's client receives RemoteMute for the screen-share-audio publication and calls pub.mute() — A and C abruptly lose the shared application's audio while the video keeps playing, and nothing in the OwnCord UI shows why (B's screen-share tile has no publisher-side audio mute control). Symmetrically, when the moderator clears the mute, MuteParticipantAudio(muted=false) force-unmutes that screen-share-audio publication again. The same happens via voice_mod_deafen, which passes c.Deafened() into the identical call. Expected per the function's own doc comment and the client's stated policy (audioElements.ts:118-120): only the microphone track (t.Source == livekit.TrackSource_MICROPHONE) should be affected.
Evidence: Server/ws/livekit.go:186-217
// MuteParticipantAudio mutes or unmutes every microphone track the participant
// publishes, so a moderator's server mute holds at the SFU instead of relying
// on the target's client to honor it.
func (c *LiveKitClient) MuteParticipantAudio(ctx context.Context, channelID, userID int64, voiceJoinToken string, muted bool) error {
...
for _, t := range p.Tracks {
if t.Type != livekit.TrackType_AUDIO {
continue
}
if _, mErr := c.roomSvc.MutePublishedTrack(ctx, &livekit.MuteRoomTrackRequest{
Room: roomName, Identity: identity, TrackSid: t.Sid, Muted: muted,
}); mErr != nil { ... }
}
Callers: Server/ws/voice_moderation.go:237 (voice_mod_mute -> MuteParticipant(..., c.Muted())) and Server/ws/voice_moderation.go:306 (voice_mod_deafen -> MuteParticipant(..., c.Deafened())).
Delivery on the target's side: node_modules/livekit-client/src/room/participant/LocalParticipant.ts:248-256 —
this.engine.on(EngineEvent.RemoteMute, (trackSid, muted) => {
const pub = this.trackPublications.get(trackSid);
if (!pub || !pub.track) return;
if (muted) pub.mute(); else pub.unmute();
});
Suggested fix: Skip screen-share audio explicitly rather than whitelisting the microphone, so audio tracks that report TrackSource_UNKNOWN (0) still get muted: in Server/ws/livekit.go:206 change the filter to if t.Type != livekit.TrackType_AUDIO || t.Source == livekit.TrackSource_SCREEN_SHARE_AUDIO { continue }. One guard in the shared function covers both the voice_mod_mute and voice_mod_deafen call sites.
OC-0328 — low — Channel unread/mention badges have no message-id replay guard at all, while the DM path has one and Channel.lastMessageId is populated for exactly that purpose but never read
Client/src/stores/channels.store.ts:346 · found 2026-08-22 · hunt general-2026-08-22-b · lens hotspot-client-tauri-client-src-lib
incrementUnread/incrementMention bump unconditionally. Channel.lastMessageId is declared (line 30) and filled from ready's last_message_id (line 100), but no call site anywhere in the client reads it — the identical registerNow->buildReady double-delivery window that OC-0242 fixed for DMs (dm.store.ts) is unguarded for server channels.
Repro: A message is broadcast into channel #general between registerNow (Server/ws/serve.go:853, which subscribes the socket) and buildReady (serve.go:884) on a fresh connect or a full resync. The server counts it in read_states.unread_count, so ready carries unread_count = 1 and last_message_id = ; ready is written straight to the connection by handshakeWrite while the broadcast waits in the client's send queue. setChannels applies unreadCount = 1, then writePump drains the queued chat_message and dispatcher.ts:711 calls incrementUnread -> the sidebar shows 2 unread for 1 message, and an @mention in it shows a mention count of 2. dm.store.ts:148 guards this exact case for DMs with messageId <= updated.lastMessageId; the channel path has no equivalent.
Evidence: channels.store.ts:346-363
export function incrementUnread(channelId: number, evenIfActive = false): void {
channelsStore.setState((prev) => {
if (prev.activeChannelId === channelId && !evenIfActive) return prev;
const existing = prev.channels.get(channelId);
if (existing === undefined) return prev;
const updated: Channel = { ...existing, unreadCount: existing.unreadCount + 1 };
...
channels.store.ts:30 / :100 — the watermark is stored and never consulted
readonly lastMessageId: number | null;
lastMessageId: ch.last_message_id ?? null,
grep -rn lastMessageId over src/ shows the only readers are dm.store.ts and SidebarDmHelpers.ts — nothing reads Channel.lastMessageId.
caller: dispatcher.ts:706-715
if ((payload.channel_id !== activeId || isDetached) && !isOwnMessage) {
incrementUnread(payload.channel_id, isDetached);
if (isMention) incrementMention(payload.channel_id, isDetached);
}
Suggested fix: Mirror the DM shape in the one shared store function rather than at the call site: give channels.store a single guarded entry point, e.g. noteChannelMessage(channelId, messageId, isMention, evenIfActive), whose setState computes const isReplay = existing.lastMessageId !== null && messageId <= existing.lastMessageId; and writes unreadCount: isReplay ? existing.unreadCount : existing.unreadCount + 1, mentionCount: isMention && !isReplay ? existing.mentionCount + 1 : existing.mentionCount, and lastMessageId: Math.max(messageId, existing.lastMessageId ?? 0) — both counters behind ONE watermark read, exactly as OC-0242 required for updateDmLastMessage (a guard split across the two functions cannot work: the first call would already have advanced the watermark). Then replace the pair at dispatcher.ts:710/713 with the single call; it is the only production caller of incrementUnread/incrementMention, so the existing exports can stay for the tests.
OC-0329 — low — DM profile note falls back to the pre-scoping unscoped key on every miss and never consumes it, so one server's private note about user N is shown for user N on every other server, forever
Client/src/components/DmProfileSidebar.ts:115 · found 2026-08-22 · hunt general-2026-08-22-b · lens hotspot-client-tauri-client-src-lib
loadNote reads the host-scoped key, and on a miss reads through to the legacy unscoped owncord:dm-note:{userId} key — but it neither migrates the value into the scoped key nor removes the legacy one. Because user ids are per-server SQLite autoincrement integers, that read-through fires on every server the user connects to. The two other modules with the same shape got this right: channel-mutes.ts:106-111 writes the scoped key and then localStorage.removeItem on the legacy one, citing OC-0288 for why leaving it in place lets every subsequent host inherit server A's data; identity.ts::migrateLegacyIdentityKey saves-then-deletes for the same reason.
Repro: On a build predating the host-scoping fix (MainPage.ts:253, OC-0177), write a DM note about user 7 on server A — it lands at owncord:dm-note:7. Upgrade. Connect to server B (a completely different host) and open the DM profile sidebar for B's user 7: loadNote misses owncord:dm-note:B:7 and returns server A's note text about a different person. The same happens on server C, D, ... because saveNote (line 121) only ever writes the scoped key and nothing ever deletes the legacy entry, so the read-through is permanent rather than one-time.
Evidence: DmProfileSidebar.ts:107-118
function loadNote(userId: number, host: string): string {
try {
if (host !== "") {
const scoped = localStorage.getItem(scopedNoteKey(userId, host));
if (scoped !== null) return scoped;
}
// Fall back to the legacy key so a note saved before per-server scoping
// (or while the host was unknown) is not silently lost.
return localStorage.getItem(legacyNoteKey(userId)) ?? "";
contrast channel-mutes.ts:106-111
if (keyExists(MUTED_KEY)) {
const legacy = parseMutedIds(loadPref<unknown[]>(MUTED_KEY, []));
writeMuted(legacy);
localStorage.removeItem(STORAGE_PREFIX + MUTED_KEY);
return legacy;
}
Suggested fix: Consume the legacy key in the one shared reader. In loadNote (DmProfileSidebar.ts:107-118), replace the fallback return localStorage.getItem(legacyNoteKey(userId)) ?? ""; with a read-migrate-delete: read the legacy value; if it is non-null and host !== "", localStorage.setItem(scopedNoteKey(userId, host), legacy); localStorage.removeItem(legacyNoteKey(userId)); before returning it (all inside the existing try/catch). One host inherits the pre-scoping note exactly once — as the existing test expects — and every later host sees an empty note.
OC-0330 — low — Pinned-messages panel prints the raw username and drops the author's user id, so a nickname can never be shown
Client/src/pages/main-page/OverlayManagers.ts:77 · found 2026-08-22 · hunt general-2026-08-22-b · lens hotspot-client-tauri-client-src-components
mapToPinnedMessage flattens the pinned message's author to msg.user.username before handing it to PinnedMessages, which renders that string verbatim (PinnedMessages.ts:54). Because the user id is discarded in the mapping, the panel cannot resolve the live membersStore nickname the way every other author surface does, so a renamed/nicknamed user is labelled differently in the pinned panel than in the message list the panel jumps into. The avatar colour is also hashed from the raw username (line 80), so the letter and colour disagree with the message row's avatar too.
Repro: 1. Give user bob the nickname "Bobby" (membersStore.displayName). 2. Pin one of bob's messages. 3. The message row in the channel reads "Bobby"; open the pin panel (pin button in the chat header) and the same pinned message is attributed to "bob". 4. Jump from the pin to the message and the name changes under you.
Evidence: OverlayManagers.ts:73-81
return {
id: msg.id,
author: msg.user.username,
content: msg.content,
timestamp: msg.created_at ?? msg.timestamp ?? "",
avatarColor: pickPinAvatarColor(msg.user.username),
};
PinnedMessages.ts:54
const authorEl = createElement("span", { class: "pinned-msg__author" }, msg.author);
members.store.ts:182 memberDisplayName() and avatar.ts:51 resolveDisplayName() are the shared resolvers every other surface uses; neither is reachable from here because PinnedMessage carries no id.
Suggested fix: Fix in the shared mapper only: widen the param to user: { id: number; username: string; avatar?: string|null; display_name?: string|null } and set author: resolveDisplayName(resolveAuthor(msg.user)) (imports from @lib/avatar and @components/message-list/formatting). Keep pickPinAvatarColor(msg.user.username) so the hue stays stable across renames. Existing tests still pass — resolveAuthor falls back to the payload/username when the member is not in the store.
OC-0331 — low — Admin API-token table parses two naive-UTC SQLite timestamps as local time, so Created / Last Used are shown shifted by the viewer's UTC offset
Server/admin/static/index.html:1679 · found 2026-08-22 · hunt general-2026-08-22-b · lens hotspot-client-tauri-client-src-components
api_tokens.created_at defaults to SQLite datetime('now') and last_used_at is written with datetime('now') — both produce "YYYY-MM-DD HH:MM:SS" in UTC with no zone suffix, and both reach the panel as plain strings (db/models.go:80-81, dbgen ListAPITokensRow.CreatedAt is string). new Date("2026-03-19 08:29:41") is a non-ISO form that V8 parses as LOCAL time, so the rendered clock time is wrong by the browser's UTC offset. The expires_at column in the very same row is correct, because CreateAPIToken formats it with an explicit Z (db/apitoken_queries.go:27) — so one column in the table is right and two are wrong.
Repro: Run the server with a browser in UTC+02:00. Mint an API token at 10:29 local (08:29 UTC). Open Admin → API Tokens: the Created column reads "08:29:41 AM" (parsed as local) instead of "10:29:41 AM". Use the token once and Last Used is wrong by the same two hours, while the Expires column for the same row — stored with a trailing Z — renders correctly, so the table contradicts itself.
Evidence: Server/admin/static/index.html:1679-1681
html+=''+(t.created_at?new Date(t.created_at).toLocaleString():'')+'';
html+=''+(t.last_used?new Date(t.last_used).toLocaleString():'<span ...>never')+'';
html+=''+(t.expires_at?new Date(t.expires_at).toLocaleString():'<span ...>never')+'';
Server/migrations/018_api_tokens.sql:19 created_at TEXT NOT NULL DEFAULT (datetime('now')),
Server/db/queries/sqlite/apitokens.sql:34 UPDATE api_tokens SET last_used_at = datetime('now') ...
Server/db/apitoken_queries.go:27 s := expiresAt.UTC().Format("2006-01-02T15:04:05Z") // <- the one that is right
Suggested fix: Add one helper next to fmtBytes and use it at the three token-table sites: function utcDate(s){return new Date(/[Zz]|[+-]\d\d:?\d\d$/.test(s)?s:s.replace(' ','T')+'Z')} then utcDate(t.created_at).toLocaleString() and utcDate(t.last_used).toLocaleString(). Passing expires_at through the same helper is a no-op (it already ends in Z) and keeps the column consistent. Server-side alternative — formatting created_at/last_used_at with an explicit Z like CreateAPIToken does — would need a migration for existing rows.
OC-0332 — low — Client auto-update is permanently and silently dead on a bare-IPv6 server — MainPage builds the updater URL without bracketing the host
Client/src/pages/MainPage.ts:845 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-2
https://${apiConfig.host} splices a possibly-bare IPv6 literal straight into a URL authority with no bracketBareIPv6Host call, unlike every other URL-building site in the client (ws.ts:544, admin-panel.ts:27, attachments.ts:60). The resulting string is not a parseable URL, so build_updater rejects it before any network call, and checkForUpdate swallows the rejection and reports "no update available".
Repro: Log in to a server saved with a bare IPv6 host, e.g. 2001:db8::1 (accepted by isValidHost, hostValidation.ts:31, and by api.setConfig). MainPage.ts:845 produces serverUrl = "https://2001:db8::1". In update_commands.rs, validate_server_url passes (the starts_with("https://") check succeeds, and its userinfo check is skipped because url::Url::parse returns Err), then build_updater line 98-100 does endpoint.parse::<url::Url>() on https://2001:db8::1/api/v1/client-update/..., which fails with an invalid-port error because the URL host parser enters the port state at the first colon and chokes on db8::1. check_client_update returns Err; updater.ts:35-38 catches it and returns {available:false, version:null, body:null} and the banner never appears. The same user never learns an update exists, on every launch, for the life of the install. Bracketed ([2001:db8::1]) and DNS/IPv4 hosts are unaffected.
Evidence: MainPage.ts:844-846
if (apiConfig.host) {
const serverUrl = https://${apiConfig.host};
const updateNotifier = createUpdateNotifier({ serverUrl });
src-tauri/src/update_commands.rs:98-100
let url: url::Url = endpoint
.parse()
.map_err(|e: url::ParseError| format!("bad endpoint URL: {e}"))?;
src/lib/updater.ts:35-38
} catch (err) {
log.error("Update check failed", { error: String(err) });
return { available: false, version: null, body: null };
}
The helper that exists for exactly this (bracketBareIPv6Host, ws.ts:146) is used by ws.ts:544, admin-panel.ts:27 and attachments.ts:60 but not here.
Suggested fix: Use the existing shared helper at the one construction site — in Client/src/pages/MainPage.ts add import { bracketBareIPv6Host } from "@lib/ws"; (ws.ts:146) and change line 845 to const serverUrl = https://${bracketBareIPv6Host(apiConfig.host)};. That mirrors ws.ts:544 / admin-panel.ts:27 / attachments.ts:60, leaves DNS, IPv4 and already-bracketed hosts byte-identical, and keeps the Rust TOFU key aligned (url::Url::host_str returns the bracketed form and tofu::cert_store_key strips the brackets back off).
Client/src/components/ChannelSidebar.ts:1010 · found 2026-08-22 · hunt general-2026-08-22-b · lens hotspot-client-tauri-client-src-components
The sidebar's only voice repaint trigger is unsubVoiceStructure, whose signature string covers channel id, mute/deafen/camera/screenshare/serverMuted/serverDeafened and E2EE verification — but not the participant's username or display name. The row label at line 431 is resolved from membersStore via memberDisplayName, and ChannelSidebar subscribes to membersStore nowhere at all (it imports it only for one-shot getState() reads). So the store writes that a rename performs — updateMemberProfile (bumps roleRevision, which only MessageList watches) and updateVoiceUserProfile (voice.store.ts:262, whose doc comment says it exists so "a rename [doesn't] leave the voice roster showing the old name for the rest of the call") — produce no re-render here. The same defect shape as the already-fixed OC finding at ChannelSidebar.ts:943 (signature omitted sessionFingerprint), on the field the roster is actually named by.
Repro: Alice and Bob are both in voice channel #general; the sidebar shows both rows. Alice opens Settings → Account and sets her nickname to "Ali" (or renames her username). The server broadcasts user_update; dispatcher.ts:929-950 patches membersStore, dmStore and voiceStore. structSig is unchanged (no identity field is in it), so renderChannels() never runs: Bob's voice sidebar keeps showing "Alice" indefinitely, while his member list, message rows and DM sidebar all show "Ali". It only corrects if some unrelated event (someone mutes, joins, leaves, or a channel changes) happens to re-render the sidebar.
Evidence: structSig += :${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${u.serverMuted === true ? "M" : ""}${u.serverDeafened === true ? "D" : ""}${verif ? @${verif.status}/${verif.safetyNumber ?? ""}/${verif.sessionFingerprint ?? ""} : ""};
// line 430-431, the label this signature is supposed to keep fresh:
const member = membersStore.getState().members.get(user.userId);
const label = (member !== undefined ? memberDisplayName(member) : user.username) || "Unknown";
Suggested fix: Add one subscription next to the existing voice ones in ChannelSidebar's setup: unsubscribers.push(membersStore.subscribeSelector((s) => s.roleRevision ?? 0, () => renderChannels()));. updateMemberProfile bumps roleRevision on every USER_UPDATE (members.store.ts:154) and always runs before updateVoiceUserProfile in the dispatcher, so this single hook covers both the displayName and username rename paths without touching structSig.
OC-0334 — low — Escape is both "close Settings" and a capturable push-to-talk key, so backing out of the PTT capture silently binds Escape as PTT
Client/src/components/settings/KeybindsTab.ts:49 · found 2026-08-22 · hunt general-2026-08-22-b · lens hotspot-client-tauri-client-src-pages
ptt_listen_for_key's allowlist includes VK 0x1B (Escape), and the capture UI has no cancel path and no aborted-signal guard — while the Rust poll is blocking for up to 10s, pressing Escape closes the Settings overlay (SettingsOverlay.ts:362-369, which even labels ESC as the close affordance) and resolves the capture with 0x1B, so updatePttKey(0x1B) runs against a torn-down tab and permanently rebinds push-to-talk to Escape without the user ever seeing the result.
Repro: 1. Open Settings → Keybinds. 2. Click the "Push to Talk" chip; it reads "Press a supported key..." and the Rust poll starts. 3. Press Escape to back out. 4. The Settings overlay closes (Escape handler) and, on key release, ptt_listen_for_key returns 0x1B, so updatePttKey(0x1B) persists Escape as the PTT binding — no visible confirmation, since the tab is gone. 5. From then on the mic is gated closed in every voice call and every Escape keypress anywhere in the OS un-gates it; if step 2-3 is done while already in a call, the mic is muted immediately by updatePttKey's mid-call gate.
Evidence: KeybindsTab.ts:49-62 — void captureKeyPress().then((vk) => { ... if (vk === 0) { restore; return; } currentVk = vk; setText(pttValue, vkName(vk)); ... void updatePttKey(vk); }) (no signal.aborted check, no cancel button).
src-tauri/src/ptt.rs:34-63 is_allowed_ptt_capture_vk — matches!(vk, 0x1B | // Escape\n 0x20 | ...).
SettingsOverlay.ts:360-369 — document.addEventListener("keydown", (e) => { if (e.key === "Escape" && root?.classList.contains("open")) options.onClose(); }).
lib/ptt.ts:262-289 updatePttKey — persists pttVk, calls ptt_set_key, starts the poller and, when a call is live, immediately setMuted(true) + setPttGated(true).
Suggested fix: Treat Escape as cancel in one place, in Rust: drop 0x1B from is_allowed_ptt_capture_vk (Client/src-tauri/src/ptt.rs:48) and, in both capture loops of ptt_listen_for_key (~ptt.rs:477 and ~ptt.rs:502), return 0 when the detected vk is 0x1B. The existing vk === 0 branch in KeybindsTab.ts:53-57 then restores the previous binding with no client change and no per-caller guard.
OC-0335 — low — ServerPanel's "Add Server" modal registers its listeners on the page-lifetime AbortSignal, so every open/close cycle permanently retains a discarded modal subtree
Client/src/pages/connect-page/ServerPanel.ts:325 · found 2026-08-22 · hunt general-2026-08-22-b · lens hotspot-client-tauri-client-src-pages
handleAddServer builds a fresh overlay on every click and wires six listeners to the ConnectPage-lifetime signal. closeModal() only does overlay.remove() — it never aborts anything — so each abandoned modal stays reachable from that signal's abort-listener list for the life of the connect page, along with its inputs and the closure state.
Repro: On the connect page click "+ Add Server" and Cancel ten times. Ten complete modal subtrees (overlay + two inputs + four buttons) are still retained through signal's abort-listener list; only navigating away from the connect page (login) releases them. The correct shape is a per-modal AbortController aborted by closeModal, as createModal/modalFactory does elsewhere.
Evidence: ServerPanel.ts:300-347 — inside handleAddServer: function closeModal(): void { overlay.remove(); } and then closeBtn.addEventListener("click", closeModal, { signal }); cancelBtn.addEventListener(...,{ signal }); saveBtn.addEventListener(...,{ signal }); overlay.addEventListener("click", ..., { signal }); modal.addEventListener("click", ..., { signal }); hostAddrInput.addEventListener("keydown", ..., { signal });
signal comes from ConnectPage.ts:66-67 (const abortController = new AbortController(); const { signal } = abortController;) and is only aborted in destroy() (ConnectPage.ts:288).
Suggested fix: Inside handleAddServer, mint const modalAc = new AbortController();, pass { signal: modalAc.signal } to all six registrations, and make closeModal() call modalAc.abort() before overlay.remove(). Chain it to the page signal with signal.addEventListener("abort", () => modalAc.abort(), { once: true, signal: modalAc.signal }) so page teardown still tears down an open modal without itself accumulating.
OC-0336 — low — ServerPanel re-registers every profile row's listeners on the page-lifetime AbortSignal on each re-render
Client/src/pages/connect-page/ServerPanel.ts:122 · found 2026-08-22 · hunt general-2026-08-22-b · lens hotspot-client-tauri-client-src-pages
renderServerProfiles clears serverListEl and rebuilds every row, attaching up to three listeners per row to the ConnectPage-lifetime signal. Nothing aborts the previous generation, so each re-render — and it is re-rendered on profile load, add, delete and every auto-login toggle — permanently pins the whole discarded row set. Same defect class as the already-fixed MemberList/SearchOverlay/QuickSwitcher/EmojiPicker re-render leaks.
Repro: On the connect page with 5 saved servers, click the auto-login (zap) toggle five times. Each click calls refreshProfiles, which rebuilds all 5 rows and adds 15 more listener registrations to the same never-aborted signal; the 25 previously-detached row elements stay reachable until the connect page itself is destroyed. A per-render AbortController (aborted at the top of renderServerProfiles) is the fix used by the sibling components already corrected for this.
Evidence: ServerPanel.ts:122-124 — function renderServerProfiles(profiles) { clearChildren(serverListEl); healthElements.clear(); for (const profile of profiles) { ... } }
ServerPanel.ts:173-181 autoLoginBtn.addEventListener("click", ..., { signal }), :193-201 deleteBtn.addEventListener("click", ..., { signal }), :206-224 item.addEventListener("click", ..., { signal }) — all inside that loop.
Callers: main.ts:590, :597, :602 and :659 all call connectPage.refreshProfiles(getProfileList()), which is serverPanel.renderProfiles (ConnectPage.ts:326-328 → ServerPanel.ts:362).
Suggested fix: Mirror MemberList.ts:475-483: hold let renderAc: AbortController | null = null; in the factory, and at the top of renderServerProfiles do renderAc?.abort(); renderAc = new AbortController();, then use renderAc.signal for the three per-row registrations (the footer/add-button listener at :115 stays on the page signal). Abort renderAc from a page-signal abort handler registered once at construction.
OC-0337 — low — liveVoiceEventsSince replays a silently truncated cold-tier window: the row cap drops the NEWEST voice events, leaving a phantom (or missing) participant in the resumed call
Server/ws/serve.go:641 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-1
The cold-tier query is ORDER BY seq ASC LIMIT n, so when the range exceeds the cap it is the newest rows that are discarded. The main replay path 220 lines above (reconnectSelectReplay, serve.go:422 and serve.go:442) explicitly detects both of that query's failure modes — len(persisted) >= coldCap (cap hit, newest dropped) and a retention-pruned prefix (oldest-seq probe) — and forces a full ready. liveVoiceEventsSince calls the identical db.GetEventsSinceForChannels with the identical cap and has neither guard, so a truncated window is handed to the client as if it were the complete voice history for that room. Worse, the cap is spent on UNFILTERED rows: the query is channel_id = 0 OR channel_id IN (chID), so global broadcasts and the DM's ordinary chat messages consume the budget, and the voice_state/voice_leave filter at serve.go:655 only runs on whatever survived. A peer's voice_leave that falls in the dropped tail is never delivered and never re-sent (the client tracks only max(seq)), so the resumed client renders a participant who has left; symmetrically, a dropped voice_state hides a peer who is really in the call, which also starves that peer of the E2EE announce/offer exchange keyed on the roster.
Repro: Config: event_persistence.enabled = true, event_persistence.replay_cold_limit = 50 (a legal value; ConfigureReplay accepts any positive int, Server/ws/hub.go:751). Alice and Bob are both in a voice call inside a 1:1 DM that Alice has since closed, so the DM id is outside Alice's allowedChannelIDs (computeAllowedChannels sources DM ids from dm_open_state) and handleReconnect takes the liveVoiceChID supplement branch at serve.go:286. Alice's socket drops. While she is offline: (1) Bob posts 60 messages into that DM — each is a persisted event on that channel_id — and then (2) Bob leaves voice, emitting voice_leave. Alice's readable channels stay quiet, so the main cold-tier replay at serve.go:417 returns well under 50 rows and succeeds (tier "db"), and the ring buffer no longer covers her last_seq. liveVoiceEventsSince then runs GetEventsSinceForChannels(lastSeq, [dmID], 50), which returns the OLDEST 50 rows — the first 50 chat messages — and drops the remaining 10 rows including Bob's voice_leave. Alice's client resumes with Bob still listed in the voice roster and never receives a correction; the same window would equally have swallowed a voice_state for a peer who joined late, leaving that peer invisible to her for the rest of the call.
Evidence: // Server/ws/serve.go:637-649 (liveVoiceEventsSince)
if buf := h.ReplayBuffer().EventsSinceFiltered(afterSeq, only); buf != nil {
raw = buf
} else if esp := h.eventStore.Load(); esp != nil {
es := *esp
persisted, err := es.GetEventsSinceForChannels(ctx, int64(afterSeq), []int64{chID}, h.maxColdReplayLimit())
if err != nil {
return nil
}
raw = make([][]byte, 0, len(persisted))
for _, p := range persisted {
raw = append(raw, p.Payload)
}
}
// no len(persisted) >= coldCap check, no oldest-seq retention probe — compare
// Server/ws/serve.go:422-453, which has both for the same query:
// case len(persisted) >= coldCap: "...the NEWEST events were dropped...forcing full ready"
// case len(oldest) == 0 || uint64(oldest[0].Seq) > lastSeq+1: "retention pruning left a gap...forcing full ready"
//
// Server/db/event_queries.go:136-144 — the cap is applied before any type filter:
// WHERE seq > ? AND (channel_id = 0 OR channel_id IN (...)) ORDER BY seq ASC LIMIT ?
// Server/ws/serve.go:653-659 — voice filtering happens only on the truncated result.
Suggested fix: Stop spending the cap on non-voice rows and stop replaying a truncated window. Smallest change: give this call its own store method that applies the type filter in SQL — WHERE seq > ? AND channel_id = ? AND event_type IN ('voice_state','voice_leave') ORDER BY seq ASC LIMIT ? — so chat and global broadcasts can no longer evict voice events from the budget, and in liveVoiceEventsSince add the sibling's cap check: if len(persisted) >= cap { slog.Warn("live voice supplement hit the row cap, skipping truncated window"); return nil }. Returning nil is the correct degradation here (a full ready is no longer available — registerNow already ran at serve.go:268 before the supplement at serve.go:287), and it restores the documented best-effort miss instead of installing a join whose matching leave was discarded. Do not simply raise the limit: that leaves the same silent-truncation hole one order of magnitude further out.
OC-0338 — low — plugin.toml silently drops resources.max_memory_mb / cpu_budget_ms — BurntSushi/toml matches Go field names, and Manifest carries only json tags
Server/plugin/manifest_toml.go:32 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-2
toml.Decode resolves a TOML key to a struct field via the toml struct tag, or, when absent, the Go field name matched with strings.EqualFold. Manifest/Resources declare only json tags, so max_memory_mb and cpu_budget_ms never match MaxMemoryMB / CPUBudgetMs (underscores break EqualFold) and are left undecoded with no error. Every other manifest key happens to be a single word (name, version, entrypoint, permissions, commands, ui, asset, …) and case-folds fine, which is why the breakage is invisible — only the two snake_case resource keys are silently discarded, and Validate() only checks >= 0, so zero passes.
Repro: Build with -tags wazero (the only build where plugin.toml is parsed at all — manifest_nottoml.go:9 stubs it out). Ship plugins/foo/plugin.toml:
name = "foo"
version = "1.0.0"
entrypoint = "foo.wasm"
permissions = ["commands"]
commands
name = "foo"
[resources]
cpu_budget_ms = 2000
max_memory_mb = 128
scanPluginDirectory (loader.go:64) loads it via tryLoadPluginTOML; Manifest.Resources is {0, 0}. Invoke /foo: sandbox_wazero.go:317 falls through to r.cfg.CPUBudgetMs (config default 100), so a command the author budgeted 2000 ms for is killed at 100 ms with "command exceeded CPU budget of 100ms". The byte-identical plugin.json ("resources": {"cpu_budget_ms": 2000}) behaves correctly, so the same plugin works as JSON and misbehaves as TOML. installFromDisk then serializes the zeroed Resources back into plugins.manifest_json (loader.go:132-138, registry.go:192-196), so the admin plugin list also reports a budget the author never wrote. No test covers TOML decoding (grep -rn toml Server/plugin/*_test.go is empty), so nothing locks this in as intended.
Evidence: manifest_toml.go:31-38
var m Manifest
if _, err := toml.Decode(string(raw), &m); err != nil { ... }
if err := m.Validate(); err != nil { ... }
manifest.go:77-80
type Resources struct {
MaxMemoryMB int json:"max_memory_mb"
CPUBudgetMs int json:"cpu_budget_ms"
}
toml@v1.6.0/decode.go:311-318 — if ff.name == key { ... } else if f == nil && strings.EqualFold(ff.name, key) { f = ff }
toml@v1.6.0/type_fields.go:108-113 — name := opts.name; if name == "" { name = sf.Name }, where opts comes from tag.Get("toml") (encode.go:647-648).
Consumer: sandbox_wazero.go:317-323
budgetMs := inst.Manifest.Resources.CPUBudgetMs
if budgetMs <= 0 { budgetMs = r.cfg.CPUBudgetMs }
if budgetMs <= 0 { budgetMs = 100 }
Suggested fix: Add toml tags to the two snake_case fields in Server/plugin/manifest.go:77-80:
type Resources struct {
MaxMemoryMB int json:"max_memory_mb" toml:"max_memory_mb"
CPUBudgetMs int json:"cpu_budget_ms" toml:"cpu_budget_ms"
}
That is the minimal fix and is safe for the JSON path (encoding/json ignores the toml tag). Optionally harden the shared decode site instead of every future field: in tryLoadPluginTOML (manifest_toml.go:31) keep the MetaData and reject leftovers — md, err := toml.Decode(...); if u := md.Undecoded(); len(u) > 0 { return nil, false, fmt.Errorf("plugin.toml: unknown keys %v", u) } — which turns any future tag/name mismatch or manifest typo into a loud load error rather than a silent zero.
OC-0339 — low — config.Load warns "unknown key ignored — value has NO effect (typo?)" for a valid, present-but-empty config section
Server/config/config.go:519 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-1
unknownFileKeys compares the file's flattened leaf keys against the defaults layer's flattened leaf keys. A YAML section header whose children are all commented out parses to a nil value, and koanf's maps.Flatten emits it as a leaf key (bare voice, telemetry, ...). The defaults layer only ever contains the dotted child paths (voice.quality, ...), never the bare section name, so every such section is reported as an unrecognised typo whose value has no effect — which is false, and which the shipped defaultYAML actively teaches operators to produce by commenting out a section's children.
Repro: Given a config.yaml such as:
server:
port: 8443
voice:
# livekit_url: "ws://localhost:7880"
# quality: "medium"
the YAML parses to map["voice"] = nil, Flatten emits the leaf key "voice", and knownKeys holds only "voice.livekit_api_key", "voice.quality", ... — never "voice". Startup therefore logs:
WARN config: unknown key ignored — value has NO effect (typo?) key=voice file=config.yaml
The operator is told a real, fully supported section is a typo. The same fires for any commented-out-children section (telemetry:, plugins:, gif:, event_persistence:, github:).
Evidence: Server/config/config.go:488-491 (allowlist = defaults-layer leaf keys only)
knownKeys := make(map[string]struct{}, len(k.Keys()))
for _, key := range k.Keys() { knownKeys[key] = struct{}{} }
Server/config/config.go:519-522
for _, key := range unknownFileKeys(cfgPath, knownKeys) {
slog.Warn("config: unknown key ignored — value has NO effect (typo?)", "key", key, "file", cfgPath)
}
Server/config/config.go:596-599 (fileK.Keys() is the flattened leaf set)
for _, key := range fileK.Keys() {
if _, ok := knownKeys[key]; !ok { unknown = append(unknown, key) }
}
koanf v2@v2.3.6 koanf.go:128-137 — Keys() ranges over confMapFlat, i.e. the maps.Flatten output.
koanf/maps@v0.1.2 maps.go:43-59 — flatten()'s type switch only recurses for map[string]interface{}; a nil section value falls to default: and is emitted as a leaf key (out["voice"] = nil).
Suggested fix: Suppress bare section names in unknownFileKeys (Server/config/config.go:596-600) rather than at the call site, so both the warning and any future caller agree. Smallest precise form — a bare key that is a prefix of a real dotted key is a known section, not a typo:
This keeps reporting a genuinely misspelled bare header (databsae:), which the blunter alternative (if fileK.Get(key) == nil { continue }) would silently swallow.
OC-0340 — low — server token create --expires <negative> silently mints a token that never expires — the exact fail-open the HTTP sibling was fixed to reject
Server/token_cli.go:118 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-3
--expires is a flag.Duration with no lower bound, and the only test applied to it is if *expires > 0. A negative duration therefore falls into the same branch as the documented 0 = never value, so expiresAt stays nil and CreateAPIToken writes expires_at = NULL. The operator asked for a bounded credential and gets a permanent one, with a success message and exit code 0. The sibling HTTP path (Server/admin/handlers_tokens.go:70) carries an explicit if req.ExpiresHours < 0 { 400 } guard with a comment saying precisely this — "Negatives must not fall into that same nil-expiresAt branch" — so the guard exists on one path and is missing on the other.
Repro: Run server token create --label ci --expires -1h (e.g. from a script that computes the remaining window and lands on a negative value, or a typo'd -1h where 1h was meant). flag.Duration parses -1h0m0s without error; *expires > 0 is false; expiresAt stays nil; the row is inserted with expires_at = NULL. stdout prints a raw token and stderr says "Created API token #N ... Store this token now", exit 0. server token list then shows EXPIRES as -. The operator believes they minted a one-hour token and has actually minted a permanent full-privilege (owner-bound by default) API credential. The same input against POST /admin/api/tokens is rejected with 400.
Evidence: Server/token_cli.go:83 expires := fs.Duration("expires", 0, "validity duration, e.g. 720h (default: never)")
Server/token_cli.go:117 var expiresAt *time.Time
Server/token_cli.go:118 if *expires > 0 {
Server/token_cli.go:119 t := time.Now().Add(*expires)
Server/token_cli.go:120 expiresAt = &t
Server/token_cli.go:121 }
Server/token_cli.go:122 id, err := database.CreateAPIToken(ctx, user.ID, auth.HashToken(raw), *label, expiresAt)
-- guarded sibling --
Server/admin/handlers_tokens.go:70 if req.ExpiresHours < 0 || req.ExpiresHours > 2436510 {
Server/admin/handlers_tokens.go:71 writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "expires_hours must be between 0 and 87600")
Suggested fix: In tokenCreate, immediately after the --label check (Server/token_cli.go:90), add the lower-bound guard the HTTP path already has: if *expires < 0 { fmt.Fprintln(os.Stderr, "error: --expires must not be negative"); return 2 }. One guard in tokenCreate covers the only caller (main.go:53 -> runTokenCLI -> tokenCreate).
OC-0341 — low — A numeric API-token label can never be revoked — token revoke commits to the id branch on parse and never falls through to the label branch
Server/token_cli.go:180 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-3
tokenRevoke documents its argument as <id|label> but dispatches purely on whether the string parses as an int64, not on whether that dispatch found anything. Labels are unvalidated free text (--label is only checked non-empty here and only trimmed in the admin handler), so an all-digit label is creatable. For such a token the id branch is taken, matches no row, and the function reports failure without ever trying RevokeAPITokenByLabel.
Repro: server token create --label 2024 --user alice mints token row id 7 with label "2024". server token revoke 2024 parses 2024 as an id, calls RevokeAPIToken(ctx, 2024), which affects 0 rows (no token has id 2024), and prints no active token matched "2024" with exit 1 — the label branch is never reached. The token stays live and, per the CLI, is unrevokable by the name the operator knows it by. (Worse if a token with id 2024 does exist: an unrelated token is revoked instead.) Making the id branch fall through to the label branch when affected == 0 fixes both halves.
Evidence: Server/token_cli.go:180 if id, perr := strconv.ParseInt(arg, 10, 64); perr == nil {
Server/token_cli.go:181 affected, err = database.RevokeAPIToken(ctx, id)
Server/token_cli.go:182 if err == nil && affected > 0 { db.WriteAudit(...) }
Server/token_cli.go:185 } else {
Server/token_cli.go:186 affected, err = database.RevokeAPITokenByLabel(ctx, arg)
Server/token_cli.go:195 if affected == 0 {
Server/token_cli.go:196 fmt.Fprintf(os.Stderr, "no active token matched %q\n", arg)
Server/token_cli.go:197 return 1
-- usage text promising both --
Server/token_cli.go:73 revoke <id|label>
Server/token_cli.go:74 Revoke a token by numeric id or by label.
Suggested fix: Make the id branch fall through instead of terminating. At Server/token_cli.go:180-190, after the id attempt, add: if err == nil && affected == 0 { affected, err = database.RevokeAPITokenByLabel(ctx, arg); if err == nil && affected > 0 { db.WriteAudit(ctx, database, 0, "api_token_revoke", "api_token", 0, arg) } }. This keeps id precedence and costs one extra query only on the miss path.
OC-0342 — low — Voice-roster avatar letter and colour are derived from the username while the name rendered beside them is the nickname
Client/src/components/ChannelSidebar.ts:420 · found 2026-08-22 · hunt general-2026-08-22-b · lens hotspot-client-tauri-client-src-components
The voice participant row builds its avatar initial and background colour from user.username, then eleven lines later resolves the label through memberDisplayName. Every other avatar in the app goes through createAvatarElement/avatarInitial, which prefer displayName (lib/avatar.ts:50-60), so a nicknamed user's voice row shows a letter that matches no other avatar of that user anywhere in the client — including the member list rendered directly above it.
Repro: Account alice sets nickname Zoe, then joins a voice channel. The sidebar voice row under that channel draws a circle containing A, coloured from the hash of "alice", immediately to the left of the text Zoe. The MemberList row for the same user (MemberList.ts:201-204 → createAvatarElement → avatarInitial) draws Z. Changing the nickname to something starting with a different letter never changes the voice row's letter or colour, because neither reads member.displayName at all.
Evidence: ChannelSidebar.ts:420-432
const initial = user.username.length > 0 ? user.username.charAt(0).toUpperCase() : "?";
const avatar = createElement("div", { class: "vu-avatar" }, initial);
avatar.style.background = pickAvatarColor(user.username);
...
const member = membersStore.getState().members.get(user.userId);
const label = (member !== undefined ? memberDisplayName(member) : user.username) || "Unknown";
lib/avatar.ts:50-60
export function resolveDisplayName(subject: AvatarSubject): string { ... return subject.username; }
export function avatarInitial(subject: AvatarSubject): string { ... return resolveDisplayName(subject).charAt(0).toUpperCase() || "?"; }
Suggested fix: Resolve the name once, before the "Unknown" fallback, and drive all three of letter, colour and label from it: const member = membersStore.getState().members.get(user.userId); const resolved = member !== undefined ? memberDisplayName(member) : user.username; const initial = resolved.length > 0 ? resolved.charAt(0).toUpperCase() : "?"; avatar.style.background = pickAvatarColor(resolved); const label = resolved || "Unknown"; — moving the members-store lookup above line 420. Keeping the initial derived from resolved rather than from label preserves the empty-username "?" assertion at channel-sidebar.test.ts:889-906.
OC-0343 — low — Tray-icon click hides a minimized window instead of restoring it — is_visible() is true while minimized and unminimize() is never called
Client/src-tauri/src/tray.rs:67 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-2
toggle_window_visibility decides show-vs-hide purely from window.is_visible(), but on both shipped platforms a minimized window still reports visible (tao Windows: IsWindowVisible, which stays TRUE for an iconified window because WS_VISIBLE is not cleared; tao GTK: gtk_widget_get_visible, TRUE for an iconified GtkWindow). So the branch taken for a minimized window is the hide branch, and neither branch ever calls unminimize(). The same file's sibling recovery path in lib.rs:52-58 (the single-instance handler) gets this right — it does unminimize(); show(); set_focus(); — which is direct in-repo evidence of the intended sequence.
Repro: On Windows or Linux: launch OwnCord, click the window's minimize button, then left-click the OwnCord tray icon expecting the window back. is_visible() returns true (minimized != hidden), so the handler takes window.hide() — the app disappears from the taskbar entirely instead of being restored. A second tray click now takes the else branch: show() maps the window again but it is still iconified (Win32 ShowWindow(SW_SHOW) restores it to its current, minimized state) and set_focus()/SetForegroundWindow does not deiconify, so on Windows the user gets back only a minimized taskbar button and must click that to actually see the app. Fix is to mirror lib.rs: check is_minimized() first, or call unminimize() before show()/set_focus() in the else branch.
Evidence: tray.rs:65-74
fn toggle_window_visibility<R: Runtime>(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
}
contrast — lib.rs:52-58 (single-instance handler, same app, same window):
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
Reachability: tauri.conf.json declares the main window with "decorations": true, "resizable": true, so the OS minimize button exists; bundle targets are nsis (Windows) + appimage/deb (Linux), i.e. exactly the two platforms where is_visible() is true while minimized. No Rust test covers tray.rs (src-tauri/tests does not exist, and tray.rs has no mod tests).
Suggested fix: Consult is_minimized() in the shared toggle so a minimized window takes the restore arm, and mirror lib.rs's sequence there. In Client/src-tauri/src/tray.rs:65-74:
One guard in the shared function fixes both callers (tray left-click at tray.rs:54 and the Show/Hide menu item at tray.rs:78). No capability change is needed — Rust-side window calls do not go through capabilities/default.json.
OC-0344 — low — ACME HTTP→HTTPS redirect hardcodes implicit port 443, but the server's documented/default HTTPS port is 8443 — every plain-HTTP visitor is 301'd to a dead port
Server/auth/tls.go:199 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-3
loadACME builds the fallback redirect as "https://" + cfg.Domain + r.URL.RequestURI() with no port. config.TLSConfig carries no port field, and the HTTPS listener actually binds :cfg.Server.Port (Server/main.go:190 addr := fmt.Sprintf(":%d", cfg.Server.Port)), whose default and documented value is 8443 (Server/config/config.go:293 Port: 8443, defaultYAML line 367, docs/deployment.md:482 "8443 | TCP | HTTPS server"). ACME mode is documented alongside that default (docs/deployment.md:238-240), and HTTP-01 validation only needs :80, so acme + 8443 is a supported deployment. The redirect therefore sends browsers to https://:443 where nothing listens. The existing test only asserts the Location prefix (Server/auth/tls_test.go:294 strings.HasPrefix(loc, "https://chat.example.com/")), so nothing pins the port.
Repro: Deploy the documented ACME configuration: server: {port: 8443} (the default) plus tls: {mode: "acme", domain: "chat.example.com"}. runStartACME (Server/main.go:509) serves tlsResult.HTTPHandler on :80; the HTTPS server binds :8443. curl -i http://chat.example.com/ returns 301 Moved Permanently with Location: https://chat.example.com/ — implicit port 443, where no listener exists — so following it gives connection refused. Because it is a 301, browsers cache the broken target. The ACME challenge path itself still works (autocert.Manager.HTTPHandler intercepts /.well-known/acme-challenge/ before the fallback), so the failure is silent at cert-issuance time and only bites real visitors. loadACME needs the HTTPS port (or should use r.Host's hostname plus the configured port) to build the target.
Evidence: Server/auth/tls.go:196-201
// HTTP handler serves ACME HTTP-01 challenges on port 80 and redirects
// all other traffic to HTTPS.
redirect := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
target := "https://" + cfg.Domain + r.URL.RequestURI()
http.Redirect(w, r, target, http.StatusMovedPermanently)
})
Server/main.go:190-191
addr := fmt.Sprintf(":%d", cfg.Server.Port)
srv := &http.Server{ Addr: addr, ... }
Server/config/config.go:293 Port: 8443
Suggested fix: Plumb the HTTPS port into loadACME rather than guessing: add a non-koanf field (e.g. HTTPSPort int) to config.TLSConfig set from cfg.Server.Port at the single LoadOrGenerate call site in Server/main.go, then in the redirect handler build the host with host := cfg.Domain; if p := cfg.HTTPSPort; p != 0 && p != 443 { host = net.JoinHostPort(cfg.Domain, strconv.Itoa(p)) } and use "https://" + host + r.URL.RequestURI(). One guard in the shared handler covers every deployment.
OC-0345 — low — ownerOnlyMiddleware re-reads a role that is already in the request context and turns a transient DB read error into 403 FORBIDDEN, ejecting the Owner from backups/updates/tokens
Server/admin/middleware.go:130 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-1
adminAuthMiddleware already resolved the principal's *db.Role via auth.ResolveTokenHash and stored it in the request context under adminRoleKey (line 90). ownerOnlyMiddleware ignores that value, issues a second GetRoleByID on the request context, and collapses err != nil into the same 403 "role not found" it uses for a genuinely missing role. This is the exact fail-closed-as-authorization-denial collapse that the perimeter branch 60 lines above was explicitly fixed for (it now answers 503 SERVICE_UNAVAILABLE and logs, precisely so a DB outage is not reported as a bad credential), and that api/middleware.go:117 was fixed for. The function's own doc comment (lines 120-121) claims it "reads the user from context ... rather than re-authenticating, avoiding redundant DB queries" — the redundant query it claims to avoid is the one that introduces the fault.
Repro: Owner is signed into the admin panel. Any transient read failure on the roles lookup (SQLITE_BUSY / "database is locked" while a scheduled backup's VACUUM INTO runs, a disk I/O error, or a context deadline on the reader pool) hits GetRoleByID during a request to one of the nine owner-only routes registered in Server/admin/api.go:148-176 — GET /admin/api/updates, POST /admin/api/updates/apply, POST /admin/api/backup, GET /admin/api/backups, DELETE /admin/api/backups/{name}, POST /admin/api/backups/{name}/restore, GET|POST /admin/api/tokens, DELETE /admin/api/tokens/{id}. The perimeter middleware immediately before it already succeeded and put the correct, non-nil Owner role in the context, so the request is fully authenticated and authorized. The Owner nevertheless receives HTTP 403 FORBIDDEN {"code":"FORBIDDEN","message":"role not found"} — the admin panel renders a permission-denied error telling the server Owner they lack the Owner role — and nothing is logged, unlike the perimeter path which logs the underlying error. Using the already-resolved adminRoleKey value (or mirroring the perimeter's 503 + slog on err != nil) makes the outcome correct.
Evidence: // middleware.go:89-93 (perimeter already stores the role)
ctx := context.WithValue(r.Context(), adminUserKey, user)
ctx = context.WithValue(ctx, adminRoleKey, role)
// middleware.go:59-69 (perimeter, after the OC fix: DB error != bad token)
default:
slog.ErrorContext(r.Context(), "admin: token resolution failed", "error", err)
writeErr(w, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "authentication service temporarily unavailable")
// middleware.go:130-134 (ownerOnlyMiddleware, unfixed sibling)
role, err := database.GetRoleByID(r.Context(), user.RoleID)
if err != nil || role == nil {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "role not found")
return
}
Suggested fix: Split the two outcomes in ownerOnlyMiddleware rather than switching to the context role. Reading adminRoleKey would be the cleaner design but it breaks both TestOwnerOnlyMiddleware_RoleNotFound and TestOwnerOnlyMiddleware_OwnerPassesThrough, which inject only adminUserKey. Smallest change that preserves every locked behavior, at Server/admin/middleware.go:130-134:
role==nil still yields 403 (test at middleware_and_spawn_test.go:211 unaffected), the owner path still yields 200, and the DB fault now matches the perimeter's 503 + slog contract.
OC-0346 — low — Panic logs can never carry trace_id — recoverer reads the trace ID before the tracing middleware that creates the span
Server/api/router.go:650 · found 2026-08-22 · hunt general-2026-08-22-b · lens explore-2
recoverer is registered at router.go:286, two slots ahead of telemetry.HTTPMiddleware() at router.go:291, and it snapshots telemetry.TraceIDFromContext(r.Context()) before calling next.ServeHTTP. At that moment no span exists in the request context (otelhttp is downstream), so TraceIDFromContext returns "" on every request and the trace_id attribute the recovery closure promises is always dropped by the if traceID != "" guard at line 666. The panic record — the one log line where trace correlation matters most — is the only one that silently loses it, while in-handler logs via logctx.go:38 get it correctly because they run inside the span.
Repro: Build with -tags otel, set telemetry.enabled=true and exporter="otlp" (or "prometheus"), then issue a request to any REST route whose handler panics (e.g. force a nil deref in a handler). The recovered-panic slog record contains method/path/panic/stack/req_id but never a trace_id attribute, even though otelhttp created a live span for that exact request and the trace is exported. Moving r.Use(telemetry.HTTPMiddleware()) above r.Use(recoverer) (or reading the trace ID inside the deferred closure instead of before dispatch) makes the same request log the real trace ID.
Evidence: router.go:286-291
r.Use(recoverer) // slog-routing panic recovery ...
r.Use(requestLogger)
r.Use(telemetry.HTTPMiddleware())
router.go:646-667
// Capture correlation IDs before dispatch ... while the
// panic log still carries req_id/trace_id.
reqID := middleware.GetReqID(r.Context())
traceID := telemetry.TraceIDFromContext(r.Context()) // <- no span yet: always ""
defer func() {
if rec := recover(); rec != nil {
...
if traceID != "" {
attrs = append(attrs, "trace_id", traceID)
}
slog.Error("http handler panic recovered", attrs...)
Build-tag dependency: telemetry_otel.go's TraceIDFromContext is the only implementation that can ever return non-empty (telemetry_default.go:26 hardcodes ""), and it reads trace.SpanContextFromContext(ctx), which is populated by otelhttp.NewHandler in (*otelProvider).HTTPMiddleware — mounted after recoverer.
Suggested fix: In routerMiddleware (Server/api/router.go:278-292) move r.Use(telemetry.HTTPMiddleware()) above r.Use(recoverer). With otelhttp outermost, recoverer's r.Context() already carries the live span, so the existing line 650 capture yields the real trace ID, and recoverer still recovers handler panics because it remains outside every route handler. This is one line in the shared stack rather than a change in recoverer, and it keeps the deferred closure free of context calls (the contextcheck constraint the comment cites). It does not disturb the ordering the file comment calls a security property — request-id binding, security headers and the body cap keep their relative positions.
OC-0347 — low — VoiceWidget's DM call label is read from dmStore but the widget never subscribes to dmStore, so it goes stale for the whole call
Client/src/components/VoiceWidget.ts:255 · found 2026-08-22 · hunt general-2026-08-22-b · lens hotspot-client-tauri-client-src-components
render() resolves a DM call's header name with dmStore.getState().channels.find(...) + dmDisplayName(dm), but mount() registers exactly three subscriptions — voiceStore (a fixed-field selector that excludes voiceUsers), uiStore.connectionStatus and channelsStore.channels. None of them fires when dmStore changes, and a DM rename/nickname change only ever touches dmStore (updateDmParticipant, dm.store.ts:246) plus voiceStore.voiceUsers (excluded from the selector). The label is therefore painted once at join and can never be corrected.
Repro: Start a DM call with Bob; the widget header reads "Bob". Bob renames himself or sets a nickname: the server sends user_update, dispatcher.ts:941 calls updateDmParticipant(bob, { username/displayName }), dmStore updates, the DM sidebar row and the chat header both repaint (ChannelController.ts:632-637 subscribes to dmStore). The VoiceWidget header still says "Bob" until the call ends. Same path leaves the header on the fallback "Voice Channel" if the dmStore row for the call's channel arrives after the join, since no later dmStore change can trigger a re-render.
Evidence: const channel = channelsStore.getState().channels.get(channelId);
const dm = dmStore.getState().channels.find((c) => c.channelId === channelId);
setText(
channelNameEl,
dm !== undefined ? dmDisplayName(dm) : (channel?.name ?? "Voice Channel"),
);
// ... mount(): unsubs.push(voiceStore.subscribeSelector(...)), uiStore.subscribeSelector((s) => s.connectionStatus, ...), channelsStore.subscribeSelector((s) => s.channels, ...) — no dmStore.
Suggested fix: In VoiceWidget.mount(), alongside the existing three subscriptions, add unsubs.push(dmStore.subscribeSelector((s) => s.channels, () => render())); — dmStore replaces the channels array on every participant patch, so the default identity comparison already fires only on real changes.
Client/src/stores/members.store.ts:247 · found 2026-08-22 · hunt general-2026-08-22-b · lens hotspot-client-tauri-client-src-components
getOnlineMembers() filters on status !== "offline", so "invisible" counts as online. Every other surface deliberately groups invisible with offline — MemberList's statusPriority (line 129-135) and isAwayStatus (line 155-157) both fold invisible into offline, with a comment saying that is where the user appears "to everybody — including, in this list, to themselves". Only the signed-in user can ever carry "invisible" locally (the server maps it to offline for everyone else), so the header over-counts by exactly one whenever you go invisible.
Repro: Sign in with two members online (you + Bob). Header reads "2 online". Set your own status to Invisible via the UserBar picker: membersStore keeps your entry at status "invisible" (the server's self-frame), the member list moves your row into the away/offline group and greys it, but SidebarArea.ts:262's getOnlineMembers().length still returns 2, so the header keeps claiming "2 online" while the list shows one.
Evidence: for (const member of s.members.values()) {
if (member.status !== "offline") {
result.push(member);
}
}
Suggested fix: Tighten the single shared selector rather than its caller — in members.store.ts getOnlineMembers, change the predicate to if (member.status !== "offline" && member.status !== "invisible"), matching MemberList's isAwayStatus.
Fixed
OC-0001 — high — Wrapped room keys have no freshness binding, so old offers replay forever
Client/src/lib/livekitE2EE.ts:772 · found 2026-08-09 · hunt voice-e2ee-2026-08-09 · lens crypto-primitives
The ephemeral ECDH keypair is generated only in setupKeyExchange (:151) and reannounceForReconnect (:316); neither rotation site (:898, :996) regenerates it, so deriveWrappingKey returns identical output all session. HKDF salt/info are constants, wrapRoomKey passes no additionalData, and the wire payload carries no epoch. handleOfferInner installs whatever decrypts.
Repro: Malicious server captures a voice_e2ee_offer, then replays it after a rotation. The recipient unwraps it successfully and installs the superseded key. Replayed to every peer, the room re-converges on a key a departed participant still holds, defeating membership forward secrecy. Aggravator at :783-789: accepting an offer sets _isKeyHolder=false and kills the rotation timer.
Evidence: e2eeCrypto.ts:30-34 constant HKDF params; :259-286 deriveWrappingKey; livekitE2EE.ts:763 epoch guard is intra-call only; :772-773 unconditional install
Fixed: 84033139 · test Client/tests/unit/livekit-e2ee.test.ts · revert-proof pass
OC-0002 — high — A dead E2EE worker is invisible; the Secured badge cannot detect it
Client/src/components/VoiceWidget.ts:196 · found 2026-08-09 · hunt voice-e2ee-2026-08-09 · lens degradation-observability
The badge is derived purely from voiceStatus === 'connected', never from the SDK's live encryption state. livekit-client emits EncryptionEvent.EncryptionError from E2eeManager.onWorkerError, and src/ subscribes to none of it (zero grep hits for EncryptionEvent, ParticipantEncryptionStatusChanged, EncryptionError, isE2EEEnabled).
Repro: The e2ee worker constructs successfully then fails asynchronously (CSP on a lazily-loaded chunk, WASM load failure, WebView2 quirk). keyProvider.setKey still resolves because it is local WebCrypto plus an EventEmitter.emit that never round-trips through the worker. Join completes, status goes connected, badge shows Secured.
Evidence: VoiceWidget.ts:196 display toggle; livekitSession.ts:376-427 createRoom; E2eeManager.ts:242-245 emits EncryptionError
Fixed: 8579cb5d · test Client/tests/unit/voice-widget.test.ts · revert-proof pass
OC-0003 — high — Unverified peers get no safety number, removing TOFU's only out-of-band escape hatch
Client/src/lib/livekitE2EE.ts:477 · found 2026-08-09 · hunt voice-e2ee-2026-08-09 · lens tofu-trust-chain
The !publishedIdentity branch accepts a peer as 'unverified' with safetyNumber: null. TOFU's designed compensation for first-contact risk is out-of-band safety-number comparison, and for exactly those peers the client renders no number to compare.
Repro: A malicious server suppresses identity_public_key for one victim pairing in ready/member_join/user_update, then substitutes the ephemeral key. The peer shows a grey shield indistinguishable from a genuine legacy client, and the user has no fingerprint to verify out of band.
Evidence: livekitE2EE.ts:473-481; the pinned-peer strip is already blocked at :458, so this branch is reachable only for never-pinned peers
Fixed: bf7612fb · test Client/tests/unit/livekit-e2ee.test.ts · revert-proof pass
OC-0004 — medium — Key-holder promotion silently no-ops when the client's own voice_state has not arrived
Client/src/lib/livekitE2EE.ts:864 · found 2026-08-09 · hunt voice-e2ee-2026-08-09 · lens keyholder-election
handleParticipantLeft early-returns when voiceUsers.get(channelId) is missing or empty. That roster is populated only by voice_state broadcasts, including the client's own. The server sends voice_token directly at voice_join.go:312 but enqueues the joiner's own voice_state on the hub broadcast queue at :337, with a GetChannelVoiceStates query in between.
Repro: Client Y joins a channel where X is holder. Y starts setupKeyExchange on the token. X leaves inside the window before Y's own voice_state is delivered; X's voice_leave arrives first, removeVoiceUser empties the channel entry (voice.store.ts:245-246 deletes it), handleParticipantLeft returns at :864 and never promotes. The server has elected Y holder; Y never learns. setupKeyExchange times out at 15s and Y is ejected with e2ee_timeout.
Evidence: livekitE2EE.ts:859-864; Server/ws/voice_join.go:312 vs :337
Fixed: 8579cb5d · test Client/tests/unit/livekit-e2ee.test.ts · revert-proof pass
OC-0005 — medium — Rotation offers exceed the server rate limit in large channels, permanently starving the same peers
Client/src/lib/livekitE2EE.ts:817 · found 2026-08-09 · hunt voice-e2ee-2026-08-09 · lens rotation-forward-secrecy
voiceE2EEOfferRateLimit is 64 per (sender, channel) per second, but voice_max_users defaults to 0 (unlimited) and admins may set up to maxVoiceLimit 99. distributeRoomKey loops over every peer with no pacing, awaiting only a fast WebCrypto wrap, so all sends land in one window. ws.send is fire-and-forget; onSendFailure covers local transport failures only, never a server ErrCodeRateLimited.
Repro: 80-person voice channel, key holder rotates, 79 offers fire inside one second, the server drops everything past 64. _peerPublicKeys iterates in stable insertion order, so the same tail peers are starved on every subsequent rotation and stay on the old key.
Evidence: Server/ws/voice_e2ee.go:23, :213-216; migrations/004_voice_optimization.sql:6; Server/admin/handlers_channels.go:148
Fixed: 8579cb5d · test Client/tests/unit/livekit-e2ee.test.ts · revert-proof pass
OC-0006 — medium — Both rotation paths call keyProvider.setKey with no session-generation guard
Client/src/lib/livekitE2EE.ts:900 · found 2026-08-09 · hunt voice-e2ee-2026-08-09 · lens rotation-forward-secrecy
handleParticipantLeft (:900) and rotateKeyPeriodically (:998) never capture or re-check _sessionGeneration around their setKey await. Every other destructive write in the file does; setupKeyExchange does it three times (:156, :174, :209). clearState bumps _sessionGeneration but does not touch keyProvider, which is one instance shared across Room objects.
Repro: A rotation's setKey is in flight when the user leaves and rejoins. The new session installs its own key; the stale setKey resolves afterwards and leaves the live encryptor holding an abandoned key. Narrow: needs two setKey promises to resolve out of order, and distributeRoomKey's ownership check already blocks the network half.
Evidence: livekitE2EE.ts:900, :998, :990 entry-only guard, :1038-1051 clearState
Fixed: 8579cb5d · test Client/tests/unit/livekit-e2ee.test.ts · revert-proof pass
OC-0007 — medium — Reconnect reaches the Secured state without confirming the room key is current
Client/src/lib/livekitE2EE.ts:329 · found 2026-08-09 · hunt voice-e2ee-2026-08-09 · lens degradation-observability
reannounceForReconnect re-applies the pre-disconnect room key and fires a single voice_e2ee_announce with no wait, no timeout, and no retry. The join path blocks on a confirmed key with a 10s attempt plus a 5s retry and aborts if it never arrives.
Repro: Network blip; the key rotates during the outage; the re-announce is lost or races the holder's own reconnect. The client sits on a dead key while the widget shows Secured, with no recovery bound short of the 5-minute rotation timer.
Evidence: livekitE2EE.ts:309-342; livekitSession.ts:535 awaited before :549/556 set connected
Fixed: 8579cb5d · test Client/tests/unit/livekit-e2ee.test.ts · revert-proof pass
OC-0008 — medium — restoreLocalVoiceState has no internal supersession guard
Client/src/lib/livekitSession.ts:834 · found 2026-08-09 · hunt voice-e2ee-2026-08-09 · lens reconnect-stale-continuations
await room.localParticipant.setMicrophoneEnabled can block for seconds on the mic-permission prompt. applyMicMuteState re-reads this._room fresh, so it acts on whichever room is live at resume time. connectAndSetup's checkpoint 3 (:1110) runs after the call returns and cannot prevent writes that happen mid-call.
Repro: Join channel A; the permission prompt stalls; the user switches to channel B; A's continuation resumes and unpublishes B's live mic using A's captured muted value.
Evidence: livekitSession.ts:834 await, unguarded writes at :845, :866-868, :871
Fixed: 7be9ccd2 · test Client/tests/unit/livekit-session.test.ts · revert-proof pass
OC-0009 — low — attemptAutoReconnect's tail has no supersession checkpoints after connected
Client/src/lib/livekitSession.ts:564 · found 2026-08-09 · hunt voice-e2ee-2026-08-09 · lens reconnect-stale-continuations
reconnectSuperseded is used exhaustively before newRoom.connect and never called again after the success setState. The tail runs unguarded, and startTokenRefreshTimer clobbers a single shared timer field that a newer session may have armed.
Repro: Reconnect for channel 5 succeeds and sets connected. During restoreLocalVoiceState or switchActiveDevice the user joins channel 9. The stale tail resumes and runs setupAudioPipeline, reapplyMuteGain, and startTokenRefreshTimer against channel 9's session.
Evidence: livekitSession.ts:564-589, no reconnectSuperseded call after :548
Fixed: 7be9ccd2 · test Client/tests/unit/livekit-session.test.ts · revert-proof pass
OC-0010 — low — handleOfferInner and handleAnnounceInner re-check generation before their final await, not after
Client/src/lib/livekitE2EE.ts:784 · found 2026-08-09 · hunt voice-e2ee-2026-08-09 · lens reconnect-stale-continuations
handleOfferInner's guard at :763 precedes the setKey await; the writes at :784 and :793 follow it. A teardown-and-rejoin-as-holder landing inside that await means :784 reads the new session's _isKeyHolder and stands it down. handleAnnounceInner has the same shape at :643 versus the write at :668.
Repro: Non-holder in channel A receives a valid offer, passes :763, and during setKey the user leaves and rejoins channel B as holder. The stale continuation sets _isKeyHolder=false for channel B and kills its rotation timer.
Evidence: livekitE2EE.ts:763 guard, :773 await, :784/:793 writes; :643 guard, :655-665 awaits, :668 write. The dangerous announce variant is blocked server-side by sendToUserIfInVoiceChannel's atomic same-channel check.
Fixed: 8787b906 · test Client/tests/unit/livekit-e2ee.test.ts · revert-proof pass
OC-0011 — low — A replayed announce overwrites a peer's live ephemeral key
Client/src/lib/livekitE2EE.ts:651 · found 2026-08-09 · hunt voice-e2ee-2026-08-09 · lens tofu-trust-chain
The signed announce message is domain || userId || ephemeralPubRaw with no channel, epoch, or nonce, so an old validly-signed announce replays cleanly. handleAnnounceInner sees a changed key and overwrites the live one, logging 'peer public key changed (reconnect?)'.
Repro: A malicious server re-emits a recorded announce carrying a retired ephemeral key. Subsequent offers are wrapped to a key nobody holds, silently denying that peer audio. Low because a malicious server can deny service more directly by not relaying.
Evidence: livekitE2EE.ts:651-670; e2eeCrypto.ts:41-45, :101-117 buildAnnounceMessage
Fixed: 8787b906 · test Client/tests/unit/livekit-e2ee.test.ts · revert-proof pass
OC-0012 — low — CleanupVoiceForChannel never clears voiceKeyHolders
Server/ws/hub_sweep.go:290 · found 2026-08-09 · hunt voice-e2ee-2026-08-09 · lens keyholder-election
Every other removal path re-elects (finishVoiceLeave, the LiveKit webhook, registerNow, rollbackVoiceJoin, sweepStaleVoiceStates). Channel delete and archive do not, leaving h.voiceKeyHolders[channelID] populated.
Repro: Delete a channel that had an elected holder. The map entry is never reachable and never freed — an unbounded per-deleted-channel leak for the process lifetime. On archive the next join's own updateKeyHolder overwrites it before any client can act, so there is no live desync.
Evidence: Server/ws/hub_sweep.go:290-346; contrast Server/ws/voice_leave.go:102
Fixed: 6f3485e7 · test TestCleanupVoiceForChannel_ClearsKeyHolder · revert-proof pass
OC-0013 — high — REST DM events never bump the visibility watermark — *ws.Hub does not implement dmVisibilityMarker
Server/api/dm_handler.go:45 · found 2026-08-12 · hunt general-2026-08-12 · lens state-desync
markDMVisibilityChanged reaches the watermark bump through a type assertion to dmVisibilityMarker, but *ws.Hub has no MarkVisibilityChanged method anywhere in the repo (grep: only api/dm_handler.go and a test double define it), so the assertion always misses. The WS-side emitter of the same unsequenced, targeted dm_channel_open does bump it unconditionally (Server/ws/emit.go:41-48), so the two sibling paths disagree: hub.visibilityChangeSeq tracks WS-originated DM opens but never REST-originated ones, and mustFullResync therefore lets a client warm-resume across a REST DM change it can never be re-sent.
Repro: Alice calls POST /api/v1/dms/group with Bob among recipient_ids while Bob's socket is momentarily down (or Bob's socket drops during the call). broadcastDMOpen (dm_handler.go:265) calls markDMVisibilityChanged — a no-op — then SendToUser(bob) returns false. Bob reconnects with last_seq>0; h.mustFullResync(lastSeq) is false because visibilityChangeSeq never moved, so handleReconnect serves a seq replay and sends auth_ok, NOT ready. dispatcher.ts's setDmChannels therefore never runs, so Bob's dmStore has no entry for the group. Chat messages in that channel do replay (computeAllowedChannels includes it via dm_open_state) but updateDmLastMessage/updateDmLastMessagePreview early-return on a channelId not in dmStore and incrementUnread no-ops, so the group DM is invisible in Bob's sidebar with no badge and no way to open it until a full logout/login. The same no-op affects handleCloseDM (dm_handler.go:218), PATCH rename, and the group-leave refresh. Note api/dm_handler_watermark_voice_test.go:57 asserts markCalls>=1 using a double that DOES implement the interface, so the suite is green while production is inert.
Evidence: type dmVisibilityMarker interface { MarkVisibilityChanged() }
func markDMVisibilityChanged(broadcaster DMBroadcaster) {
if vm, ok := broadcaster.(dmVisibilityMarker); ok {
vm.MarkVisibilityChanged()
}
}
// contrast, same file:
var _ dmVoiceEvictor = (*ws.Hub)(nil) // sibling capability IS compile-time asserted; dmVisibilityMarker is not
Suggested fix: Add func (h *Hub) MarkVisibilityChanged() { h.bumpVisibilityWatermark() } in Server/ws (e.g. hub.go next to bumpVisibilityWatermark), and add var _ dmVisibilityMarker = (*ws.Hub)(nil) in Server/api/dm_handler.go mirroring the existing dmVoiceEvictor compile-time assertion at line 63 so a future rename cannot silently re-break it.
Fixed: db0275a2 · test Server/ws/hub_visibility_watermark_test.go · revert-proof pass
OC-0014 — high — Client refreshes the LiveKit token every 23 hours while the server mints it with a 5-minute TTL, so auto-reconnect fails for any voice session older than 5 minutes
Client/src/lib/livekitSession.ts:714 · found 2026-08-12 · hunt general-2026-08-12 · lens flow-voice
Two sources of truth for the same credential disagree by three orders of magnitude. Server/ws/livekit.go sets tokenTTL = 5 * time.Minute and documents "The client requests a refresh via voice_token_refresh before expiry"; the client's only periodic refresh is TOKEN_REFRESH_MS = 23h. Nothing else re-requests a token: requestTokenRefresh() is called only from that timer and once right after a successful reconnect, and it early-returns when this._room === null (which is the case throughout "reconnecting"). handleDisconnected hands deps.getLatestToken() straight to attemptAutoReconnect, which passes it to newRoom.connect(resolvedUrl, token).
Repro: Join voice, stay connected for >5 minutes, then drop the SFU connection (Wi-Fi blip, laptop sleep, SFU restart). handleDisconnected (roomEventHandlers.ts:161-197) starts attemptAutoReconnect with the join-time token, which expired at T+5min. Both attempts fail JWT validation at LiveKit, the loop exhausts, and line 640 runs this.leaveVoice(true); leaveVoiceChannel(); onErrorCallback("Voice connection lost — failed to reconnect"). The user is ejected from the call for a blip that the reconnect path exists to absorb. The stale comment at livekitSession.ts:780-786 ("Sessions longer than the 4h TTL…", "The 23h refresh timer ensures a fresh token is always ready before the original expires") describes a TTL the server no longer uses. Note tests/unit/livekit-session.test.ts:2817 hardcodes the 23h advance, so it locks the constant but asserts nothing about the interop contract.
Evidence: Client/src/lib/livekitSession.ts:714
private static readonly TOKEN_REFRESH_MS = 23 * 60 * 60 * 1000;
Server/ws/livekit.go:28
// Short-lived (5 min) to limit replay window (BUG-127). The client requests
// a refresh via voice_token_refresh before expiry.
const tokenTTL = 5 * time.Minute
Suggested fix: Lower LiveKitSession.TOKEN_REFRESH_MS below the server TTL — e.g. 4 * 60 * 1000 (refresh 1 min before the 5-min expiry) — and update the stale KNOWN LIMITATION comment (livekitSession.ts:777-786) plus the three test constants that advance the timer by 23h. Optionally also have handleDisconnected request a refresh before starting attemptAutoReconnect, but the timer change alone restores the invariant the server comment documents.
Fixed: 8579cb5d · test Client/tests/unit/livekit-session.test.ts · revert-proof pass
OC-0015 — high — A failed voice channel-switch leaves the client live in a voice call (mic hot, audio flowing) with the voice UI completely hidden and no way to leave
Client/src/lib/dispatcher.ts:812 · found 2026-08-12 · hunt general-2026-08-12 · lens flow-voice
The VOICE_LEAVE handler unconditionally calls the store's leaveVoiceChannel() whenever the event is about the local user (if (isSelf) leaveVoiceChannel();), even though the sibling effect three lines above — actually tearing down the LiveKit session — is correctly gated on shouldTeardownSession (payload.channel_id matching the store's current channel). During a channel switch the store's currentChannelId has already been optimistically set to the NEW channel by VoiceCallbacks.onVoiceJoin before the server responds, so the self voice_leave broadcast for the OLD channel (which the server always sends first, per voice_join.go's h.handleVoiceLeave(ctx, c) call before minting a token for the new channel) makes shouldTeardownSession false — but leaveVoiceChannel() still runs and blanks voiceStore.currentChannelId to null. In the normal success path this is harmless because a voice_state broadcast for the new channel (VOICE_STATE handler, dispatcher.ts:745, joinVoiceChannel(payload.channel_id)) arrives shortly after and restores currentChannelId. But when the switch fails server-side — e.g. voice_join.go:158-179's LeaveVoiceChannelIfMatch retry for the old channel's DB row fails, so GetVoiceState still returns the old row and the join is aborted — the server never sends a voice_token or a voice_state for either channel; it only sends a generic ErrCodeInternal error ('voice channel switch failed — please try again'), which dispatcher.ts's S.ERROR handler does not specially handle for this code (it falls through to the generic setTransientError toast at dispatcher.ts:1002, with no voiceStore write). So voiceStore.currentChannelId is permanently stuck at null with nothing left to restore it. Meanwhile voice_leave.go's finishVoiceLeave() unconditionally calls h.livekit.RemoveParticipant(ctx, oldChID, c.userID, oldJoinToken) (voice_leave.go:108-114) regardless of whether the DB delete succeeded, forcibly kicking the client's still-live LiveKit Room object (the client never ran connectAndSetup()/leaveVoice() for this failed switch, since it never got a voice_token) out of the SFU. That kick fires roomEventHandlers.ts's handleDisconnected with a non-CLIENT_INITIATED reason; its auto-reconnect branch (roomEventHandlers.ts:172-198) decides whether to reconnect using deps.getCurrentChannelId(), which is LiveKitSession's own internal _currentChannelId getter (livekitSession.ts:207-211, derived from _state) — completely independent of voiceStore.currentChannelId. Since _state was never touched by the failed switch, _currentChannelId still points at the old channel with a valid cached token/URL, so attemptAutoReconnect silently reconnects the client back into the old channel's LiveKit room, republishes the microphone (restoreLocalVoiceState), and sets voiceStatus to 'connected' (livekitSession.ts:548-556) — all without ever calling joinVoiceChannel() to resync voiceStore.currentChannelId.
Repro: User is in voice channel A (fully joined, mic live). They click to switch to channel B (VoiceCallbacks.onVoiceJoin optimistically sets voiceStore.currentChannelId=B and sends voice_join). Server-side, handleVoiceJoin leaves channel A first; the DB's LeaveVoiceChannelIfMatch delete for the A row transiently fails (busy DB, timeout, etc.) but RemoveParticipant(A) and the voice_leave(A) broadcast still fire unconditionally. The client's VOICE_LEAVE(A) handler blanks voiceStore.currentChannelId to null (dispatcher.ts:812) since shouldTeardownSession is false (currentChannelId was already B) so it never calls session.leaveVoice(). Server then finds the stale A row still present, aborts the switch, restores its own hub state to channel A, and returns only a generic error — no voice_token/voice_state ever reaches the client, so nothing ever sets currentChannelId back to A or B. The client's still-live Room for channel A, kicked by RemoveParticipant, fires handleDisconnected, which — driven by LiveKitSession's own internal channel state, not the store — auto-reconnects back into channel A's LiveKit room and republishes the microphone. End state: voiceStore.currentChannelId is null (VoiceWidget.render() at VoiceWidget.ts:220 hides the entire widget when null, and ChannelSidebar.ts:335's isJoined is also false for row A) while the user is actually connected to channel A's SFU with a live, transmitting microphone and an intact E2EE session — invisible to the user, who has no on-screen mute/leave/status affordance until they happen to click channel A's row again (which itself would only start a new join attempt, tearing down the phantom session as a side effect of connectAndSetup's if (this._room !== null) this.leaveVoice(false)).
Suggested fix: Gate the teardown on the SESSION's live channel rather than only the store's: in the VOICE_LEAVE handler, tear down when isSelf and the LiveKit session's current channel id equals payload.channel_id (expose it from livekitSession alongside leaveVoice). The stale-leave protection test still holds (after a completed rejoin the session's channel is the new one), and every failed-switch variant then converges to a clean idle state instead of a hidden live session. Server-side hardening (send a voice_state resync in the voice_join abort branch) can follow, but the client guard alone removes the hot-mic state.
Fixed: 8579cb5d · test Client/tests/unit/dispatcher.test.ts · revert-proof pass
OC-0016 — high — Re-opening a channel visited earlier in the session renders a permanently stale message window — loadMessages short-circuits on isChannelLoaded and nothing invalidates on switch
Client/src/pages/main-page/MessageController.ts:76 · found 2026-08-12 · hunt general-2026-08-12 · lens flow-message
loadMessages returns immediately when the channel is already in loadedChannels, and loadedChannels is only ever cleared by invalidateLoadedMessageWindows() (dispatcher's second-ready full-resync path) and clearChannelMessages() — which has no caller anywhere in src/. Combined with the focus-scoped fan-out above, every message posted in a channel while the user was viewing a different one is absent from the store, never delivered live, and never refetched. The stale window is what MessageList renders on the way back, with no gap indicator and no way for the user to force a refresh short of restarting the app.
Repro: Open channel A (50 messages fetched, loadedChannels = {A}). Switch to channel B — the server unsubscribes the socket from channel:A. Ten messages are posted in A; none reach this client. Switch back to A: MainPage's activeChannelId subscriber calls mountChannel(A) → loadMessages(A) → isChannelLoaded(A) is true → early return. MessageList renders the 50-message snapshot from the first visit; the 10 new messages are missing with no "has more below" affordance, and stay missing for the rest of the session (scroll-up only calls loadOlderMessages, which prepends).
Evidence: Client/src/pages/main-page/MessageController.ts:76-79 if (isChannelLoaded(channelId)) { log.debug("Messages already loaded", { channelId }); return; }
Client/src/pages/main-page/ChannelController.ts:245 void msgCtrl.loadMessages(channelId, signal); — the only load on mount; mountChannel does not clear the window
Client/src/stores/messages.store.ts:746 clearChannelMessages — grep -rn "clearChannelMessages" src/ matches only its own definition
Client/src/lib/dispatcher.ts:311 invalidateLoadedMessageWindows(); — reached only when hasReceivedReadyBefore (a full-ready resync)
Suggested fix: In ChannelController.mountChannel, when previousChannelId !== null, drop that channel from loadedChannels (export a store helper mirroring reattachToPresent's Set-delete, without requiring the detached flag) so the next visit refetches the live tail; setMessages' existing merge already preserves pending/failed rows and newer live rows, so the refetch cannot clobber in-flight state.
Fixed: b1fb565 · test Client/tests/unit/channel-controller.test.ts · revert-proof self-reported
OC-0017 — high — Virtual scroll window never follows the scroll position — rows outside the initial ±20-item overscan render as blank space
Client/src/components/MessageList.ts:536 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
renderWindow() only rebuilds DOM when renderedStart < 0, and the only two callers that set that sentinel are renderAll() and scrollToMessage(). Every scroll-driven call therefore lands in the else branch, which is a pure no-op — it does not even update the spacers its own comment on line 496-497 claims it updates. The rendered window is frozen wherever the last data-change rebuild left it, so scrolling into the top/bottom spacer shows an empty region with no rows, and nothing ever fills it.
Repro: Open a channel whose full history is loaded (hasMoreMessages(channelId) === false) and that holds ~300 messages. mount() → renderAll() positions the window at the tail (~41 items ≈ 2.5k px). Scroll up past that: handleScroll → requestAnimationFrame → renderWindow() → renderedStart is 0-or-greater → else branch → nothing rendered. The area above the frozen window is the top spacer (offsetBefore(renderedStart) px of empty div) and stays blank indefinitely, because the scroll-top fetch is gated on hasMoreMessages and no store update fires. The only escape is an unrelated store event (new message, role revision bump) that triggers renderAll and re-centres the window on the current scrollTop. tests/unit/message-list.test.ts:244 ("scrollToMessage renders a target that was outside the rendered window") documents the same frozen-window behaviour rather than locking it as intended.
Evidence: if (renderedStart < 0) { … full rebuild … } else {
// Scroll-driven: no-op. The ResizeObserver handles measurement and
// spacer updates when element sizes change.
}
// called from: handleScroll → scrollRafId = requestAnimationFrame(() => { scrollRafId = 0; renderWindow(); })
Suggested fix: In renderWindow's else branch, detect that the freshly computed [start,end) range is not contained in [renderedStart,renderedEnd) and take the rebuild path in that case (the existing >30-rebuilds-per-2s renderWindowCount breaker already guards the image-height oscillation loop the no-op was written to avoid); keep the no-op only when the target range is already fully rendered.
Fixed: b1fb565 · test Client/tests/unit/message-list.test.ts · revert-proof self-reported
OC-0018 — high — voice_join into a 1:1 DM has no block gate — a blocked user can enter the blocker's DM voice room and publish audio to them
Server/ws/voice_join.go:64 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-ws
Every other 1:1-DM interaction sink routes through service.requireDMNotBlocked (send, edit, delete, react, pin, typing, and call_ring — see service/message_perms.go:92-118, whose own doc comment claims it is "called from every DM interaction sink"). The voice path's only gate is hasChannelAccess, which by construction never consults blocks, and grep shows the entire ws/ package contains no IsEitherBlocked / requireDMNotBlocked call. Blocking never touches dm_participants (service/block.go:48 calls only st.BlockUser), so IsDMParticipant still returns true and the blocked user passes straight through.
Repro: Bob blocks Alice (PUT /api/v1/blocks/{alice}). Alice sends {"type":"voice_join","payload":{"channel_id":<their 1:1 DM channel id>}} over WS. hasChannelAccess passes: the default Member role holds CONNECT_VOICE 0x200 (migrations/007_member_video_permissions.sql sets Member = 0x1E63) and IsDMParticipant(alice, dm) is still true because BlockUser does not remove participant rows. handleVoiceJoin then persists a voice_states row, mints a LiveKit token with RoomJoin + CanPublishSources ["microphone", "camera", "screen_share"] for room channel- (ws/livekit.go:110-121), and broadcastVoiceEvent resolves the DM audience to its participants (ws/hub_broadcast.go:149-166), so Bob's client receives Alice's voice_state and renders her as present in that DM's call (Client dispatcher.ts:740 -> updateVoiceState). Alice can repeat this within the 5/s voice_join limit to spam Bob's UI with voice_state/voice_leave, and if Bob is in that room her microphone audio reaches him. The identical channel's call_ring is correctly refused with FORBIDDEN — the block is enforced on the doorbell but not on the door.
Evidence: ws/voice_join.go:64 — if !h.requireChannelAccess(ctx, c, channelID, permissions.ConnectVoice, "CONNECT_VOICE") { is the only authorization on the join; ws/deps.go:190-193 — "Blocking is deliberately not consulted here: it is the message paths' rule (service.requireDMNotBlocked), it is two-party only, and a blocked user is still a participant, so it is orthogonal to the non-participant hole this closes."; the same gate is reused for re-minting at ws/voice_join.go:418 (hasChannelAccess(... permissions.ConnectVoice) in handleVoiceTokenRefreshV2). Contrast service/dm.go:368 (requireDMNotBlocked inside RingTargets), added for A-2026-08-03 and locked by ws/dm_group_call_test.go:344 TestCallRing_BlockedOneToOneForbidden.
Suggested fix: Expose service.requireDMNotBlocked (e.g. a DMService method) and call it in handleVoiceJoin right after the ch.Type == "dm" branch (voice_join.go:82-85), refusing with FORBIDDEN; group DMs are already exempt inside requireDMNotBlocked. Reuse the same call in handleVoiceTokenRefreshV2 next to its hasChannelAccess gate (voice_join.go:418) so a mid-session block also evicts on refresh.
Fixed: 8579cb5d · test Server/ws/voice_dm_access_test.go · revert-proof pass (manual: voice_join.go reverted alone, DMBlocked tests red, green at HEAD)
OC-0019 — medium — Disconnect teardown decides replaced before a multi-second voice cleanup, then stamps the already-reconnected user offline
Server/ws/serve_pumps.go:186 · found 2026-08-12 · hunt general-2026-08-12 · lens ws-hub
readPump's defer samples replaced := hub.unregisterNow(c) at line 148 and then reuses that stale boolean at line 186 to gate MarkUserDisconnected (196) and the global offline presence broadcast (203). Between those two points it runs hub.handleVoiceLeave(cleanupCtx, c) (157), which does a DB delete, a per-connected-user permission scan in channelReadAudience, and a livekit.RemoveParticipant HTTP call bounded only by lkTimeout = 5s (Server/ws/livekit.go:151). A reconnect that registers during that window is invisible to the stale flag, so the dead socket's teardown marks the live session offline. hub_sweep_test.go:87 documents that replaced exists precisely so "a reconnect's teardown does not mark the live connection's user offline" — the guard is simply evaluated too early to hold.
Repro: User U is connected as client A and is in voice channel V; LiveKit is unreachable/slow. (1) A's socket drops. readPump's defer snapshots voiceChID=V and calls unregisterNow(A), which finds A in h.clients, deletes it, and returns replaced=false. (2) The defer enters handleVoiceLeave, which blocks up to 5s in RemoveParticipant. (3) U's client reconnects: authenticateConn succeeds, handleReconnect (or handleFreshConnect) calls registerNow(B) so h.clients[U]=B, then applyConnectStatus writes users.status='online' and announceConnectPresence broadcasts presence{U, online}. (4) A's defer resumes with the stale replaced=false: MarkUserDisconnected(U) flips users.status back to 'offline' (db/dbgen/users.sql.go:185) and BroadcastToAll(presence{U, offline}) reaches every peer. Result: U is live on socket B but renders offline on every already-connected client, and a client that connects later reads users.status='offline' from ListMembers (presentableMembers only downgrades non-connected users, never upgrades). Nothing re-announces until U changes status or reconnects again.
Evidence: 148: replaced := hub.unregisterNow(c)
156: if voiceChID != 0 && !replaced {
157: hub.handleVoiceLeave(cleanupCtx, c) // DB + audience scan + 5s LiveKit call
186: if !replaced {
196: _ = hub.db.MarkUserDisconnected(cleanupCtx, c.userID)
203: hub.BroadcastToAll(buildPresenceMsg(c.userID, db.StatusOffline, nil))
Suggested fix: In readPump's defer (and unregisterFailedHandshake), re-evaluate liveness at decision time instead of reusing the pre-cleanup snapshot: gate the MarkUserDisconnected + offline broadcast on !replaced && hub.GetClient(c.userID) == nil (any entry present after unregisterNow removed c is necessarily a newer connection), evaluated immediately before line 196 — after handleVoiceLeave returns.
Fixed: 7be9ccd2 · test Server/ws/serve_pumps_reconnect_race_test.go · revert-proof pass
OC-0020 — medium — Stale _isKeyHolder survives a voice-channel switch made while the SFU is reconnecting, so the client joins the new channel as a phantom key holder
Client/src/lib/livekitE2EE.ts:188 · found 2026-08-12 · hunt general-2026-08-12 · lens voice-e2ee
setupKeyExchange ORs the server-authoritative is_key_holder with whatever _isKeyHolder already holds. Its stated justification is that clearState() always runs between sessions (so a non-false residue can only be an in-window handleParticipantLeft promotion). That invariant is broken by connectAndSetup, which only tears E2EE state down via if (this._room !== null) this.leaveVoice(false); (livekitSession.ts:933) — and _room (livekitSession.ts:202) is null in the reconnecting state. A join issued while the LiveKit auto-reconnect loop is running therefore reaches setupKeyExchange with _isKeyHolder still true from the previous channel, and the server's false is discarded.
Repro: 1. User (uid 5) is alone/lowest in voice channel A, so the server sent is_key_holder=true; _isKeyHolder === true, rotation timer armed.
2. The LiveKit SFU connection drops (network blip). handleDisconnected -> setRoom(null) -> setReconnectAc(ac) puts _state in reconnecting (livekitSession.ts:319-334). The WS socket is unaffected, so the sidebar's onVoiceJoin guard (socketLive(), VoiceCallbacks.ts:173) still passes.
3. During the reconnect loop (MAX_RECONNECT_ATTEMPTS=2, RECONNECT_DELAY_MS=3000, plus URL resolution/connect time) the user clicks voice channel B, which already has a lower-uid participant. Server: computeIsKeyHolder(B, 5) -> false, sends voice_token with is_key_holder=false.
4. handleVoiceToken -> state is reconnecting, so neither the connected fast path nor the _connecting queue applies -> connectAndSetup(...). this._room is null, so leaveVoice(false) is skipped and _e2ee.clearState() never runs.
5. setupKeyExchange(false, B) executes this._isKeyHolder = false || true -> true. The client bumps the epoch, generates its OWN room key, applies it to the shared keyProvider, arms a 5-minute rotation timer, sends only an announce, and returns true immediately — skipping the entire non-key-holder wait/timeout path.
6. connectAndSetup proceeds to room.connect() and setVoiceStatus("connected"). The client now publishes SFrame-encrypted audio under a key nobody in B holds and cannot decrypt any peer, while the UI reports the call connected/secured. Every voice_e2ee_offer it sends is rejected server-side with NOT_KEY_HOLDER (Server/ws/voice_e2ee.go:198). Recovery depends entirely on B's real key holder answering the announce with an offer (handleOfferInner's stand-down at livekitE2EE.ts:783); if that offer never arrives — holder is TOFU-blocked on us, rate-limited (voice_e2ee.go:214-223), or mid-join — the client stays silently deaf and mute forever, because the 10s+5s e2ee_timeout safety net that would have ejected a real non-holder was never entered. Meanwhile the phantom rotation timer regenerates a fresh useless room key every 5 minutes.
Evidence: livekitE2EE.ts:188 this._isKeyHolder = isKeyHolder || this._isKeyHolder;
livekitE2EE.ts:190-203 if (this._isKeyHolder) { this._e2eeEpoch++; this._roomKey = generateRoomKey(); await this.keyProvider.setKey(...); this.startKeyRotationTimer(); }
livekitE2EE.ts:230-232 if (this._isKeyHolder) { ...send announce... } else { /* wait up to 10s+5s for an offer, else return false */ }
livekitSession.ts:933 if (this._room !== null) this.leaveVoice(false);
livekitSession.ts:202-204 private get _room(): Room | null { return this._state.type === "connected" ? this._state.room : null; }
livekitSession.ts:329-357 teardownForReconnect — tears down the audio pipeline/tracks, never calls _e2ee.clearState()
livekitSession.ts:1370-1379 leaveVoice() is the sole caller of this._e2ee.clearState()
Suggested fix: In connectAndSetup, treat superseding an in-flight reconnect the same as superseding a live room: change livekitSession.ts:933 to if (this._room !== null || this._state.type === "reconnecting") this.leaveVoice(false);. leaveVoice(false) aborts the stale reconnect AbortController and runs _e2ee.clearState(), bumping _sessionGeneration so line 188's OR can only preserve promotions that land during THIS setupKeyExchange call (the B3-2 behavior), never residue from a prior session.
Fixed: 8579cb5d · test Client/tests/unit/livekit-session.test.ts · revert-proof pass
OC-0021 — medium — Login builds a rate-limiter key from the unvalidated username, so an unauthenticated caller pins ~1 MiB of heap per request for 6 hours
Server/api/auth_handler.go:353 · found 2026-08-12 · hunt general-2026-08-12 · lens api-authz
handleLogin never length-checks req.Username (its sibling handleRegister calls auth.ValidateUsername, max 32 runes, before touching anything). The raw string becomes a RateLimiter map key, and RateLimiter.Allow inserts that key into the shard map before the limit test, while RateLimiter.Cleanup only evicts an entry once every recorded timestamp is older than rateLimiterCleanupMaxWindow — 6 hours. An attacker-chosen, body-sized key is therefore retained for 6 hours per attempt on an endpoint that requires no credentials.
Repro: POST /api/v1/auth/login with body {"username":"<1 MiB of 'a'>","password":"x"}. The user does not exist, so GetUserByUsername returns (nil,nil), execution reaches line 366-367, and "login_user_fail:" + the 1 MiB string is stored in RateLimiter.shards[h].windows. The response is 401, but the key stays resident until a Cleanup pass finds its timestamp older than 6 hours. The route's own IP limiter permits loginRateLimitPerMinute = 5 such requests per minute per source, i.e. ~5 MiB/min retained, ~1.8 GiB resident at steady state from a single IP (more from several). Sending 10 identical oversized usernames additionally trips the per-username lockout, which persists the same ~1 MiB key into the lockouts table via RateLimiter.Lockout -> UpsertLockout, and that row is reloaded into memory by NewPersistentRateLimiter on every restart. The identical input to POST /api/v1/auth/register is rejected at api/auth_handler.go:192 before any allocation.
Evidence: api/auth_handler.go:323 unameKey := strings.ToLower(req.Username)
api/auth_handler.go:324 userLockKey := "login_user_lock:" + unameKey
api/auth_handler.go:353 userFailKey := "login_user_fail:" + unameKey
api/auth_handler.go:366 if !limiter.Allow(failKey, loginFailureThreshold+1, loginFailureWindow) ||
api/auth_handler.go:367 !limiter.Allow(userFailKey, loginUserFailureThreshold+1, loginUserFailureWindow) {
auth/ratelimit.go:134 e, ok := s.windows[key]
auth/ratelimit.go:136 e = &entry{}
auth/ratelimit.go:137 s.windows[key] = e // inserted even when the call is then refused
auth/ratelimit.go:249 cutoff := time.Now().Add(-maxWindow) // maxWindow == 6h in production
auth/ratelimit.go:255 for key, e := range s.windows { ... if ts.After(cutoff) { allStale = false } }
auth/ratelimit.go:263 if allStale { delete(s.windows, key) } // entry survives ~6h after its last use
api/constants.go:133 rateLimiterCleanupMaxWindow = 6 * time.Hour
api/router.go:52 r.Use(MaxBodySizeUnless(defaultMaxBodySize, ...)) // defaultMaxBodySize = 1 MiB, /auth/login not exempt
Suggested fix: In handleLogin, reject or clamp an over-long username before building unameKey — e.g. after the empty check add if len(req.Username) > maxUsernameKeyLen { return 400 } (or truncate the value used to key the limiter), so an unbounded body-sized string can never become a retained map/DB key. maxUsernameLength (32 runes) is the natural bound.
Fixed: 8579cb5d · test Server/api/auth_handler_test.go · revert-proof pass
OC-0022 — medium — The archived-channel read-only gate exists only on SendMessage; edit, reaction, pin and purge still mutate an archived channel
Server/service/message_crud.go:268 · found 2026-08-12 · hunt general-2026-08-12 · lens api-authz
SendMessage refuses an archived channel (message_crud.go:54) because "any caller that still held the id ... could keep posting into an archive indefinitely". EditMessage routes its non-DM gate through checkSendPermission, which carries no archived check, and the same is true of handleReaction, SetMessagePinned and PurgeMessages. So the archive is still writable: an author can inject arbitrary new text into an archived channel and it is fanned out as chat_edited to every reader with READ_MESSAGES, and a MANAGE_MESSAGES holder can still pin or bulk-delete there.
Repro: Admin PATCHes /admin/api/channels/{id} with archived=true. Alice, who previously posted message M in that channel and still holds its id, sends the WS chat_edit command for M with new content: EditMessage passes checkSendPermission (her base READ|SEND bits are untouched by archiving) and commits the new text, broadcasting chat_edited to every client that can read the channel — while the identical chat_send is refused with ErrForbidden "channel is archived" (locked by service/archived_channel_readonly_test.go). The same holds over REST: POST /api/v1/channels/{id}/pins/{messageId} (api/channel_handler.go:73) and POST /api/v1/channels/{id}/messages/purge (api/channel_handler.go:70) both succeed against the archived channel.
Evidence: message_crud.go:54 if !isDM && ch.Archived { return ...ErrForbidden: channel is archived } // send only
message_crud.go:268 } else if permErr := s.checkSendPermission(ctx, userID, msg.ChannelID, chanType); permErr != nil {
// comment: "an edit injects new text into the channel and is fanned out to every
// reader, so it must clear the same gate as a send" — but checkSendPermission
// (message_perms.go:69-90) never consults ch.Archived
message_query.go:215 } else if !s.perms.HasChannelPerm(ctx, userID, channelID, ReadMessages|ManageMessages) // SetMessagePinned, no archived check
message_purge.go:55 same, PurgeMessages
message_reactions.go:116 same, handleReaction
admin/handlers_channels.go:260 "Archiving hides a voice channel the same way deleting it does — nobody can see it or reach it afterward"
Suggested fix: Add the archived read-only check to the shared write policy so all mutation paths inherit it: put if ch.Type != "dm" && ch.Archived { return ErrForbidden } inside checkSendPermission (covers EditMessage and CanPost), and add the same guard to SetMessagePinned, PurgeMessages and handleReaction (which bypass checkSendPermission), ideally via one requireWritableChannel(ch) helper called from every write sink.
Fixed: 8579cb5d · test Server/service/archived_channel_readonly_test.go · revert-proof pass
OC-0023 — medium — ListMembers hides users whose temporary ban has lapsed, while every other path treats them as active
Server/db/queries/sqlite/users.sql:58 · found 2026-08-12 · hunt general-2026-08-12 · lens db-storage
ListMembers filters on the raw u.banned = 0 column, but nothing ever clears banned when ban_expires passes — expiry is evaluated lazily by auth.IsEffectivelyBanned (auth/helpers.go:73) and by db.notBannedClause (db/mention_queries.go:40). A user whose temp ban has lapsed can therefore authenticate (ws/serve_auth.go:85), post, and be resolved as an @mention/@everyone target, yet is absent from the members[] roster the ready payload is built from (ws/serve_ready.go:153 -> db/auth_queries.go:545). The two sources of truth disagree — exactly the hazard the notBannedClause comment was written to close, applied to mentions but not to the roster.
Repro: Ban user B with a 1-hour expiry (ModerationService.BanUser -> db.BanUser writes banned=1, ban_expires=now+1h). Wait for the expiry to pass. B logs in: api/middleware.go:131 and ws/serve_auth.go:85 both call auth.IsEffectivelyBanned, which returns false, so the connection is accepted. B sends a message and is a valid @mention target (GetUserIDsByUsernames uses notBannedClause). But every connected client's ready payload — B's own included — omits B from members[], because ListMembers still sees banned=1. Result: B's messages render with no member entry (no avatar, no role colour), B is missing from the member sidebar and from mention autocomplete, and B cannot be opened from the roster. TestListMembers_ExcludesBanned (db/auth_queries_test.go:755) only covers a permanent ban (expires=nil), so this case is not test-locked.
Evidence: users.sql:53-59
-- name: ListMembers :many
SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name), u.identity_public_key,
u.display_name, u.custom_status
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.banned = 0
ORDER BY u.username ASC;
-- vs db/mention_queries.go:40 (the same question, answered differently)
const notBannedClause = (banned = 0 OR (ban_expires IS NOT NULL AND replace(ban_expires, ' ', 'T') <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')))
Suggested fix: Via the db-change skill, change ListMembers' WHERE clause in Server/db/queries/sqlite/users.sql to the same lapsed-ban test as db.notBannedClause: WHERE (u.banned = 0 OR (u.ban_expires IS NOT NULL AND replace(u.ban_expires, ' ', 'T') <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))), then regenerate Server/db/dbgen/.
Fixed: 7be9ccd2 · test Server/db/auth_queries_test.go · revert-proof pass
OC-0024 — medium — channel_focus re-subscribes after a concurrent visibility revoke, leaving a demoted user permanently subscribed to a channel they can no longer READ
Server/ws/handlers.go:170 · found 2026-08-12 · hunt general-2026-08-12 · lens concurrency
The READ_MESSAGES check for channel_focus happens inside the handler (service/channel.go:245), but the pub/sub Subscribe that acts on it happens later, in the applier, with two SQLite round-trips in between. Nothing re-validates at Subscribe time, and the revoke sweeps (Hub.RefreshChannelVisibility / Hub.revokeUnreadableChannels) only ever Unsubscribe what the socket holds at the instant they run — so a Subscribe landing after the sweep is never undone.
Repro: User U is a member of role R, currently focused on channel 5. U's client sends channel_focus{channel_id:7} (7 is readable at that moment). On U's readPump goroutine, HandleChannelFocus passes the READ check at service/channel.go:245 and then blocks in GetLatestMessageID/UpdateReadState. Concurrently an admin POSTs a channel_overrides change denying R READ_MESSAGES on channel 7: admin/handlers_channel_perms.go:164 calls permInvalidator.InvalidateAll(), then :167 calls hub.RefreshChannelVisibility(ch7), which sends U a channel_delete, runs pubsub.Unsubscribe(c, ChannelTopic(7)) (a no-op — U is not subscribed yet, focus is still 5) and clears c.channelID if it equals 7 (it does not). The admin request finishes. U's handler now returns SetChannelID=7, and handlers.go:170 runs pubsub.Subscribe(c, ChannelTopic(7)) plus sets c.channelID=7. U is now subscribed to channel 7's topic with no READ permission and nothing left to revoke it: every subsequent chat_message / chat_edited / chat_deleted / reaction_update published to channel 7 is delivered to U for the remaining lifetime of the socket. The same window exists for revokeUnreadableChannels on a role reassignment (hub_broadcast.go:534-562).
Evidence: handlers.go applier:
if result.SetChannelID != nil {
oldChID := c.getChannelID()
c.mu.Lock(); c.channelID = *result.SetChannelID; c.mu.Unlock()
newChID := *result.SetChannelID
if oldChID != newChID {
if oldChID > 0 { c.hub.pubsub.Unsubscribe(c, ChannelTopic(oldChID)) }
if newChID > 0 { c.hub.pubsub.Subscribe(c, ChannelTopic(newChID)) } // <- line 170, no re-check
}
}
service/channel.go HandleChannelFocus (the only gate):
} else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) { // line 245
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
}
latestID, err := s.st.GetLatestMessageID(ctx, channelID) // DB round trip 1
if err == nil { _ = s.st.UpdateReadState(ctx, userID, channelID, latestID) } // DB round trip 2
hub_broadcast.go RefreshChannelVisibility (the revoke, line 350-356):
c.sendMsg(buildChannelDelete(ch.ID))
h.pubsub.Unsubscribe(c, ChannelTopic(ch.ID))
c.mu.Lock(); if c.channelID == ch.ID { c.channelID = 0 }; c.mu.Unlock()
Suggested fix: In the handlers.go applier, after pubsub.Subscribe(c, ChannelTopic(newChID)), re-validate access with a live check (hasChannelAccess, as used by requireChannelAccess) and on failure Unsubscribe + clear c.channelID. Subscribe-then-recheck closes the window in both orders: a revoke committing before the recheck is seen by the recheck; a revoke committing after finds the subscription present and its sweep removes it.
Fixed: db0275a2 · test Server/ws/handler_focus_revoke_race_test.go · revert-proof pass
OC-0025 — medium — enableCamera has no supersession re-check after publishTrack, so a concurrent disableCamera leaves the server and every peer believing the camera is on
Client/src/lib/screenShare.ts:243 · found 2026-08-12 · hunt general-2026-08-12 · lens concurrency
The generation guard is checked only after device acquisition (line 235), not after the awaited publishTrack. A disableCamera that runs during the publish round-trip bumps the generation, unpublishes/stops the track and sends voice_camera{enabled:false}; the superseded enableCamera then resumes and sends voice_camera{enabled:true}, so the last frame the server sees says the camera is on while the local store says off and no track exists.
Repro: In a live voice channel the user clicks the camera toggle on. enableCamera acquires the device, sets state.manualCameraTrack and awaits room.localParticipant.publishTrack (an SFU negotiation round trip, tens to hundreds of ms). Before it resolves the user clicks the toggle off (or the dispatcher's VIDEO_LIMIT/error handler calls disableCamera — dispatcher.ts:992/1018). disableCamera bumps state.generation, stopManualCameraTrack clears state.manualCameraTrack and stops the MediaStreamTrack, setLocalCamera(false) runs and voice_camera{enabled:false} is sent. publishTrack then resolves; enableCamera continues past line 243 with no generation check and sends voice_camera{enabled:true} at line 251. Server-side ordering is false then true, so the DB row and the voice_state broadcast say camera=true: every peer renders a camera tile for a participant whose track was stopped, while the local voiceStore has localCamera=false, so the user's next toggle click sends enabled:true again and there is no single click that turns it off.
Evidence: if ((state.generation ?? 0) !== generation) { // 235 - only guard
videoTrack.stop();
return;
}
state.manualCameraTrack = videoTrack; // 242
await room.localParticipant.publishTrack(videoTrack, { // 243 - awaited, no guard after
...
});
const sendId = ws.send({ type: "voice_camera", payload: { enabled: true } }); // 251
export async function disableCamera(state, deps) {
bumpGeneration(state); // 276
stopManualCameraTrack(state, room); // unpublishes + stops the in-flight track
...
finally { setLocalCamera(false); ws.send({ type: "voice_camera", payload: { enabled: false } }); }
Suggested fix: After the awaited publishTrack (and before the ws.send at 251), re-check (state.generation ?? 0) !== generation; on supersession, unpublish and stop videoTrack, clear state.manualCameraTrack if it still points at it, and return without sending voice_camera(true).
Fixed: b1fb565 · test Client/tests/unit/screen-share-tracks.test.ts · revert-proof self-reported
OC-0026 — medium — enableScreenshare has no supersession re-check across its publish loop, so a concurrent stop still announces the share as on
Client/src/lib/screenShare.ts:345 · found 2026-08-12 · hunt general-2026-08-12 · lens concurrency
Same missing post-await guard as enableCamera, but worse: the loop publishes tracks from the local screenTracks array while disableScreenshare has already emptied state.manualScreenTracks, so the remaining publishes are made against tracks the disable path can no longer reach, and the final ws.send announces enabled:true after the disable already announced enabled:false.
Repro: The user picks a window in the OS share picker; every quality preset requests audio alongside video, so enableScreenshare enters the loop at line 345 with two tracks and awaits the first publishTrack. The user then hits the app's Stop Sharing button (or the OS 'Stop sharing' bar fires the 'ended' listener registered at line 358 for a previous share). disableScreenshare bumps the generation, stopManualScreenTracks sets state.manualScreenTracks = [] and stops/unpublishes both tracks, setLocalScreenshare(false) runs, and voice_screenshare{enabled:false} is sent. The loop resumes and publishes the second track — held only by the local screenTracks closure variable, which state.manualScreenTracks no longer references, so no later disable can unpublish it — and line 370 sends voice_screenshare{enabled:true}. The server's last observed state is enabled:true, peers keep a screenshare tile for the participant, and the local store says screenshare off.
Evidence: if ((state.generation ?? 0) !== generation) { // 334 - only guard
for (const t of screenTracks) t.stop();
return;
}
state.manualScreenTracks = screenTracks; // 341
for (const track of screenTracks) {
await room.localParticipant.publishTrack(track, { // 345 - awaited per track, no guard
...
});
}
const sendId = ws.send({ type: "voice_screenshare", payload: { enabled: true } }); // 370
export async function disableScreenshare(state, deps) {
bumpGeneration(state); // 399
stopManualScreenTracks(state, room); // sets state.manualScreenTracks = [] and stops them
...
finally { setLocalScreenshare(false); ws.send({ type: "voice_screenshare", payload: { enabled: false } }); }
Suggested fix: Re-check (state.generation ?? 0) !== generation after each awaited publishTrack in the loop (and before the ws.send at 370); on supersession, unpublish/stop every track in the local screenTracks array, clear state.manualScreenTracks if it still references them, and return without sending voice_screenshare(true).
Fixed: b1fb565 · test Client/tests/unit/screen-share-tracks.test.ts · revert-proof self-reported
OC-0027 — medium — HTTP listen failure returns from run() without hub.GracefulStop(), orphaning the companion livekit-server process and leaving the maintenance goroutine's stop channel unclosed
Server/main.go:383 · found 2026-08-12 · hunt general-2026-08-12 · lens lifecycle
hub.GracefulStop() — the only caller of LiveKitProcess.Stop() — is a plain statement at line 401, not a defer, and the serve-error branch returns at line 383 before reaching it. LiveKitProcess.Stop() is what cancels the context passed to exec.CommandContext, so skipping it leaves the spawned livekit-server child alive; Go does not kill children when the parent exits, so it is reparented and keeps holding :7880 and the 50000-60000 UDP range. close(stopMaintenance) (line 407) is likewise skipped, so the 15-minute maintenance goroutine survives the whole deferred teardown, including database.Close() at line 133.
Repro: Configure voice.livekit_binary (or voice.auto_download_livekit: true) and start the server while another process holds the configured server.port. api.NewRouter spawns livekit-server via LiveKitProcess.Start. The listen loop retries 20 times, then pushes the bind error onto serveErr; run() returns at line 383, main() calls os.Exit(1) — hub.GracefulStop() never runs, so LiveKitProcess.Stop() never cancels its context and the livekit-server child outlives the OwnCord process. Restarting OwnCord then fails LiveKit startup with :7880 already in use. The same return also strands the maintenance goroutine, which can be mid-DeleteExpiredSessions(bgCtx) when the deferred database.Close() executes.
Evidence: select { case err := <-serveErr: if err != nil { return fmt.Errorf("server error: %w", err) } ... } // line 380-387
...
hub.GracefulStop() // line 401 — never reached on the serveErr path
if err := srv.Shutdown(shutdownCtx); err != nil { return ... }
close(stopMaintenance) // line 407 — also never reached
// router.go:164-169 already started the companion process by this point:
proc := ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir)
if startErr := proc.Start(); startErr != nil { ... } else { hub.SetLiveKitProcess(proc) }
Suggested fix: Add defer hub.GracefulStop() immediately after router, hub, routerCleanup := api.NewRouter(...) (main.go:198). gracefulOnce makes it idempotent with the explicit call at line 401 on the normal path, and it guarantees LiveKitProcess.Stop() runs on every early return.
Fixed: db0275a2 · test Server/main_test.go · revert-proof pass
OC-0028 — medium — buildReady drops the user's own live voice room when it is not READ-visible, wiping the client's call roster on a full resync
Server/ws/serve_ready.go:276 · found 2026-08-12 · hunt general-2026-08-12 · lens state-desync
buildReady filters every voice_state through visibleSet = READ-visible non-DM channels ∪ the user's open DM channels. Voice membership is gated on CONNECT_VOICE alone (voice_join.go:64) and DM visibility comes from dm_open_state, so the room the user is currently in can be absent from visibleSet — the exact hole handleReconnect patches on the replay tier via liveVoiceEventsSince (serve.go:341-343, whose comment names 'a DM voice call after the DM was closed' as the stock case). The full-ready tier has no equivalent supplement, so the ready payload asserts the user is in no voice channel while the server's voice_states row, the hub's c.voiceChID and the LiveKit session all say otherwise.
Repro: Alice and Bob are in a 1:1 DM voice call. Alice closes the DM from the sidebar (DELETE /api/v1/dms/{id}); CloseDM's non-group branch only deletes her dm_open_state row and leaves result.Left false, so no voice eviction runs — she stays in the call. Alice's socket then drops and her resume takes the full-ready path (mustFullResync, or a buffer/cold-tier miss). buildReady's dmChannels comes from GetUserDMChannels (dm_open_state), so the DM id is not in visibleSet and BOTH voice_state rows are filtered out; payload.voice_states is empty. Client-side setVoiceStates (Client/src/stores/voice.store.ts:185) then does voiceUsers: channelMap — a full replacement with an empty map — while currentChannelId: autoJoinChannel ?? prev.currentChannelId keeps her in the channel, and selfState is undefined so localServerMuted/localServerDeafened are reset to false. Result: a live, audible call rendering zero participants (including herself), setLocalSpeaking permanently a no-op (it early-returns when voiceUsers.get(channelId) is undefined), and any moderator server-mute gate silently lifted in the UI. Nothing repopulates her own row until she toggles mute herself. The same happens for any voice channel where an override grants CONNECT_VOICE but denies READ_MESSAGES.
Evidence: visibleSet := make(map[int64]struct{}, len(visibleChannels)+len(dmChannels))
for i := range visibleChannels { visibleSet[visibleChannels[i].ID] = struct{}{} }
for i := range dmChannels { visibleSet[dmChannels[i].ChannelID] = struct{}{} }
voiceStates := make([]db.VoiceState, 0, len(allVoiceStates))
for i := range allVoiceStates {
if _, ok := visibleSet[allVoiceStates[i].ChannelID]; ok {
voiceStates = append(voiceStates, allVoiceStates[i])
}
}
Suggested fix: In buildReady, before filtering, seed visibleSet with the channel of the user's own voice row: scan allVoiceStates for a row with UserID == userID and add its ChannelID to visibleSet (the user's own live room can never leak — they are in it). This mirrors liveVoiceEventsSince's rationale on the replay tier.
Fixed: db0275a2 · test Server/ws/serve_ready_own_voice_test.go · revert-proof pass
OC-0029 — medium — buildReady swallows three DB errors and ships an authoritative-looking empty snapshot; the client wipes its DM list, member list and unread badges
Server/ws/serve_ready.go:242 · found 2026-08-12 · hunt general-2026-08-12 · lens error-paths
Inside one function, ListChannels/ListRoles/GetChannelOverridesFor failures abort the handshake (return nil, err), but ListMembers (l.153), GetChannelUnreadCounts (l.188) and GetUserDMChannels (l.242) failures are downgraded to slog.Warn plus an empty value, and the ready frame is then built and sent as if it succeeded. ready is the protocol's full-state snapshot, so the client cannot distinguish "the query failed" from "you genuinely have none" — the error is mapped to success on the wire.
Repro: A server restart makes every client reconnect at once and take the full-ready path; under that load one GetUserDMChannels read returns SQLITE_BUSY (or hits the request ctx deadline). The server logs a warning and sends ready with dm_channels: []. Client/src/lib/dispatcher.ts:331-335 documents the exact opposite contract — "the server always sends the field, so an empty array is an authoritative 'no open DMs' ... and must clear ghosts from dmStore" — so setDmChannels([]) wipes the user's whole DM list, and the reconcile loop at dispatcher.ts:347-366 then deletes every dm-typed mirror row from channelsStore. If the user was viewing a DM, stillPresent at dispatcher.ts:285-292 is false, so setActiveChannel(null) tears down the open conversation. Every DM is unreachable for the rest of the session: a ready is only re-sent on a fresh connect or a full resync, and successful seq-replay reconnects never send one. The same interleaving on ListMembers empties the member sidebar (removing the "Message" affordance that is the only way back to a DM), and on GetChannelUnreadCounts zeroes every channel's unread_count/mention_count/last_message_id.
Evidence: members, err := database.ListMembers(ctx)
if err != nil {
slog.Warn("buildReady ListMembers", "err", err)
members = []db.MemberSummary{}
}
...
unreadMap, err := database.GetChannelUnreadCounts(ctx, userID)
if err != nil {
slog.Warn("buildReady GetChannelUnreadCounts", "err", err)
unreadMap = map[int64]db.ChannelUnread{}
}
...
dmChannels, err := database.GetUserDMChannels(ctx, userID)
if err != nil {
slog.Warn("buildReady GetUserDMChannels", "err", err)
dmChannels = []db.DMChannelInfo{}
}
// contrast, same function, lines 144-151:
channels, err := database.ListChannels(ctx)
if err != nil { return nil, fmt.Errorf("buildReady ListChannels: %w", err) }
Suggested fix: In buildReady, treat the three per-user loads like ListChannels: on error from ListMembers, GetChannelUnreadCounts, or GetUserDMChannels, return nil, fmt.Errorf(...) so the handshake fails and the client's reconnect logic retries, instead of shipping empty values the protocol defines as authoritative.
Fixed: 7be9ccd2 · test Server/ws/serve_ready_error_propagation_test.go · revert-proof pass
OC-0030 — medium — prependMessages trims the tail at the 500-row cap, silently destroying the user's pending/failed optimistic rows
Client/src/stores/messages.store.ts:602 · found 2026-08-12 · hunt general-2026-08-12 · lens ordering-boundary
Optimistic rows (status "pending"/"failed") are appended at the END of a channel's array by addOptimisticMessage, and prependMessages trims with combined.slice(0, MAX_MESSAGES_PER_CHANNEL) — i.e. it drops the tail. Every other writer that replaces a channel window (setMessages L415-424, setAroundMessages L492, invalidateLoadedMessageWindows L545) deliberately carries non-"sent" rows across, with the comment "they are the only copy of the user's composed text". prependMessages is the one path that does not, so an unsent/failed message and its Retry draft are deleted with no server copy to restore them (the comment at L597-599 claims the dropped tail is "restored via the detached-window machinery", which is only true for rows the server actually has).
Repro: 1. Open a channel with plenty of history. MAX_MESSAGES_PER_CHANNEL = 500, PAGE_SIZE = 50.
2. Send a message while the socket is down (or let a send fail): addOptimisticMessage appends a row with id 0 and status "pending"/"failed" at the end of messagesByChannel[ch]; the composer text now exists ONLY in that row (Retry/Delete render off it).
3. Scroll up repeatedly. Each loadOlderMessages -> prependMessages adds up to 50 older rows at the head. After ~10 pages the array reaches 500.
4. On the next scroll-up, combined.length = 550 > 500, so wasTrimmed is true and combined = combined.slice(0, 500) keeps the first 500 (oldest) rows and discards the last 50 — which include the optimistic row.
5. The failed message and its text are gone from the store forever; pendingSends still holds the correlationId, and nothing re-renders a Retry affordance. Contrast step 4 with setMessages, which slices merged.slice(merged.length - MAX) and therefore preserves the same rows.
Evidence: let combined = [...converted, ...existing];
// ...
const wasTrimmed = combined.length > MAX_MESSAGES_PER_CHANNEL;
if (wasTrimmed) {
combined = combined.slice(0, MAX_MESSAGES_PER_CHANNEL);
}
Suggested fix: In prependMessages' trim branch, carry non-'sent' rows out of the dropped tail: if (wasTrimmed) { const kept = combined.slice(0, MAX_MESSAGES_PER_CHANNEL); const carried = combined.slice(MAX_MESSAGES_PER_CHANNEL).filter((m) => m.status !== "sent"); combined = carried.length > 0 ? [...kept, ...carried] : kept; } — mirroring the carry every other window-replacing writer already performs.
Fixed: b1fb565 · test Client/tests/unit/messages-store-detached.test.ts · revert-proof self-reported
OC-0031 — medium — Channel drag-reorder assumes distinct positions; tied positions make the drop a silent no-op or land the channel in the wrong slot
Client/src/components/channel-sidebar/drag-reorder.ts:151 · found 2026-08-12 · hunt general-2026-08-12 · lens ordering-boundary
The mouseup handler reassigns "the group's own existing position slots" by sorting the category's position values ascending and zipping them onto the new id order. That is only correct when the positions are distinct. channels.position has no uniqueness constraint (Server/db/queries/sqlite/channels.sql has no unique index, AdminUpdateChannel/CreateChannel store whatever is given, and the admin panel's Create Channel modal ships value="0" for the Position field — Server/admin/static/index.html:925). When every channel in a category shares position 0, slots is [0,0,0,...] and ch.position !== newPosition is false for every row, so reorders stays empty, drag.onReorder is never called, no PATCH is sent, and updateChannelPosition is never applied — the drag silently does nothing and the row snaps back. With partial ties the zip assigns the wrong slot to the wrong channel, so the dragged channel lands somewhere other than where it was dropped.
Repro: 1. In the admin panel, create three text channels in category "Text Channels" without editing the Position field: general, random, dev. All three are stored with position = 0 (Server/admin/handlers_channels.go:117 -> AdminCreateChannel with req.Position = 0).
2. In the desktop client, signed in as a MANAGE_CHANNELS holder, getChannelsByCategory sorts them by position (all 0, so stable Map-insertion order): [general, random, dev].
3. Drag dev and drop it on the top half of general.
4. reorderedIds = [dev, general, random]; slots = [0,0,0].
5. Loop: i=0 -> dev, newPosition 0, dev.position is already 0 -> skipped. i=1 -> general, 0 == 0 -> skipped. i=2 -> random, 0 == 0 -> skipped.
6. reorders.length === 0, so drag.onReorder(reorders) at L166-168 never fires. No adminUpdateChannel PATCH is issued and the store is never updated; the sidebar re-renders in the original order. The drag is unrecoverably a no-op for as long as the tie exists.
Partial-tie variant: positions [general=0, random=0, dev=5]; dragging dev to the front yields dev->0 (changed, sent), general->0 (skipped), random->5 (changed, sent), leaving general and dev both at 0 — the resulting order depends on Map iteration order rather than the drop.
Evidence: const slots = drag.channels.map((c) => c.position).sort((a, b) => a - b);
const reorders: ChannelReorderData[] = [];
for (let i = 0; i < reorderedIds.length; i++) {
const id = reorderedIds[i];
const newPosition = slots[i];
...
const ch = drag.channels.find((c) => c.id === id);
if (ch !== undefined && ch.position !== newPosition) {
reorders.push({ channelId: id, newPosition });
updateChannelPosition(id, newPosition);
}
}
if (reorders.length > 0) {
drag.onReorder(reorders);
}
Suggested fix: After sorting, make the slot list strictly increasing before zipping: for (let i = 1; i < slots.length; i++) { if (slots[i]! <= slots[i - 1]!) slots[i] = slots[i - 1]! + 1; } — tied groups then get distinct positions, the reorder fires, and subsequent renders order deterministically, while categories with already-distinct slots keep their exact existing range (the behavior the offset test at drag-reorder.test.ts:360 locks).
Fixed: b1fb565 · test Client/tests/unit/drag-reorder.test.ts · revert-proof self-reported
OC-0032 — medium — Client's lastSeq watermark is never reset by a full-ready resync, so it desyncs permanently from the server's seq counter (and then silently skips events)
Client/src/lib/ws.ts:307 · found 2026-08-12 · hunt general-2026-08-12 · lens flow-reconnect
lastSeq is monotone-increasing (if (seq > lastSeq) lastSeq = seq) and is only ever zeroed by disconnect() (logout). The server answers an unusable last_seq by sending a full ready and stamps replay_source: "none" into auth_ok, but the client ignores that field and keeps the stale watermark forever. Once the server's counter is below the client's watermark (any restart where MAX(events.seq) is 0 — event_persistence.enabled=false, an events table emptied by the 24h pruner, a restored DB), the two counters never re-converge, and while the server's counter climbs back through the stale value the client asks for a range the server happily answers as a complete resume.
Repro: Server with event_persistence.enabled=false (or an events table emptied by the pruner). Client is connected long enough to reach lastSeq=5000, then the server restarts, so h.seq starts at 0. (a) Immediate effect: every subsequent reconnect takes the full-ready tier (ringbuffer.go:66 afterSeq > newestSeq), and dispatcher.ts:301-327 fires invalidateLoadedMessageWindows() + a full getMessages refetch each time, forever. (b) Data loss: the client stays connected while the server's counter climbs to 4990 (received live, lastSeq stays pinned at 5000 because 4990 < 5000). The socket drops; during the reconnect backoff the server broadcasts up to seq 5090. The client reconnects with last_seq=5000; the 1000-entry ring buffer holds 4091..5090, so afterSeq(5000) > oldestSeq(4091) and afterSeq <= newestSeq(5090) both pass and the server replays only 5001..5090 with replay_source: "buffer". Events 4991..5000 — real chat_message/chat_deleted/channel_update frames the client missed while offline — are never delivered, no ready arrives, and no history refetch is triggered.
Evidence: ws.ts:256-260 const seq = ...; if (seq > lastSeq) { lastSeq = seq; } — the only write outside disconnect().
ws.ts:307-320 auth_ok branch: replayDedup = null; setState("connected"); reconnectAttempt = 0; startHeartbeat(); — payload.replay_source (Server/ws/serve_ready.go:49, "none" for fresh/full resync) is never read and lastSeq is never reset.
ws.ts:427 last_seq: lastSeq is sent unconditionally on every auth frame.
ws.ts:598 lastSeq = 0; inside disconnect() only.
Server side: Server/ws/ringbuffer.go:66 if afterSeq > rb.newestSeqLocked() { return nil } forces full ready while the server is behind, and Server/main.go:208-213 only seeds h.seq when cfg.EventPersistence.Enabled and maxSeq > 0.
Suggested fix: In ws.ts's auth_ok branch (line ~307), reset the watermark when the server declares a full resync: if ((msg.payload as { replay_source?: string }).replay_source === "none") lastSeq = 0; before setState("connected"). The next sequenced frame then adopts the server's current epoch via the existing seq > lastSeq update.
Fixed: 7be9ccd2 · test Client/tests/unit/ws-reconnect.test.ts · revert-proof pass
OC-0033 — medium — A DM send survives a transient GetDMParticipantIDs failure by silently dropping live fan-out to everyone, including the sender
Server/service/message_crud.go:174 · found 2026-08-12 · hunt general-2026-08-12 · lens flow-message
SendMessage already committed the message row via CreateMessageWithMentions before this block runs. If s.st.GetDMParticipantIDs then errors (transient DB hiccup, lock contention), the function logs and does return result, nil with result.ParticipantIDs left nil and result.IsDM=true. handleChatSendV2 (Server/ws/handlers_chat.go:101-105) unconditionally builds MessageSentDMEvent{participantIDs: result.ParticipantIDs} from that nil slice. EmitEvents routes it as a SequencedDMEvent to Hub.sendSequencedToUsers(channelID, nilUserIDs, payload) (Server/ws/hub_broadcast.go:607-619), which still allocates a seq and pushes into the replay ring buffer/EventPersister, but its for _, userID := range userIDs loop is a no-op over the empty slice, so h.SendToUser is never called for anyone -- not even the sender. SendMessage returns err=nil, so chat_send_ok still goes to the sender (their optimistic row reconciles fine), but the other DM participant(s) get no chat_message frame, no unread/mention bump, no last-message preview update, and no notification. They only learn about the message on their own NEXT reconnect, because only a fresh 'ready' recomputes unread_count from the DB independent of WS delivery -- a recipient who stays continuously connected never sees the message land at all.
Repro: Users A and B share a DM channel, both connected. A sends a message at the moment s.st.GetDMParticipantIDs(ctx, channelID) returns a transient error for this one call (Server/service/message_crud.go:174-178). CreateMessageWithMentions already succeeded, so the row is in the DB. SendMessage returns (result, nil) with ParticipantIDs=nil; handleChatSendV2 emits MessageSentDMEvent{participantIDs: nil}; sendSequencedToUsers allocates seq N, stores it in the replay buffer, and iterates zero recipients. A's client gets chat_send_ok and shows the message locally; B's client (still connected, no reconnect) never receives seq N live, never bumps its DM badge, and never shows the message -- until B happens to disconnect and reconnect, which is the only path that recomputes unread_count from the DB.
Suggested fix: Query the participants with a cancellation-proof context — participantIDs, pErr := s.st.GetDMParticipantIDs(context.WithoutCancel(ctx), p.ChannelID) — matching the pattern SendMessage already uses for its other post-commit side effects (compensating deletes, applyMentionCounts, audit writes), which eliminates the deterministic sender-disconnect trigger; for the residual genuine-DB-error case, have handleChatSendV2 fall back to emitting MessageSentChannelEvent when result.IsDM && result.ParticipantIDs is empty, so ChannelTopic delivery still reaches any participant currently viewing the DM.
Fixed: db0275a2 · test Server/service/message_crud_test.go · revert-proof pass
OC-0034 — medium — Aborted voice-channel switch restores the server's voice state but never undoes the voice_leave it already broadcast — the user is stuck in a phantom voice session nobody (including themselves) can see
Server/ws/voice_join.go:174 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-ws
When the pre-switch leave fails to delete the voice_states row, handleVoiceJoin aborts and restores the client's voice state, VoiceTopic subscription and key-holder entry. But finishVoiceLeave has already broadcast voice_leave to an audience that explicitly includes the leaver themselves (voice_leave.go:96-99), and nothing re-broadcasts a voice_state afterwards. Server, DB and the SFU keep the user in the channel while every client — the user's own included — has removed them.
Repro: 1. User U is in voice channel A; voice_states holds (U, A, joined_at=T) and U's client holds token T.
2. U sends voice_join{channel_id: B}.
3. handleVoiceJoin (voice_join.go:151) runs handleVoiceLeave. Inside finishVoiceLeave, leaveVoiceChannelWithRetry fails to remove the row — either the synchronous DELETE errors (SQLITE_BUSY under concurrent writes; the background retries have not landed yet) or the client's join token is empty, in which case voice_leave.go:129 skips the DELETE entirely and returns nil.
4. finishVoiceLeave still broadcasts voice_leave{A, U} to the READ audience, the remaining room participants, AND U (voice_leave.go:96).
5. Back in handleVoiceJoin, GetVoiceState still returns the row for A, so the abort branch at voice_join.go:165 runs: c.setVoiceState(A, T), Subscribe(VoiceTopic(A)), updateKeyHolder(A), and an INTERNAL error to U. No voice_state is broadcast.
End state: the hub, the voice_states row and the LiveKit participant all still have U in channel A, but every connected client removed U from A's roster, and U's own client ran leaveVoice(false) + leaveVoiceChannel() and shows "not in voice". U cannot rejoin A — handleVoiceJoin:124 answers ALREADY_JOINED — while still consuming a slot in JoinVoiceChannelIfCapacity's COUNT(*) and still appearing in every freshly built ready payload (buildReady reads GetAllVoiceStates). The stale-voice sweep cannot heal it either: sweepStaleVoiceStates only reaps rows whose channel disagrees with the client's voiceChID, and the abort deliberately made them agree.
Evidence: voice_join.go:151-179
h.handleVoiceLeave(ctx, c) // -> finishVoiceLeave broadcasts voice_leave (incl. to c)
vs, err := h.db.GetVoiceState(ctx, c.userID)
...
if vs != nil {
slog.Warn("handleVoiceJoin: stale voice state persists after leave, aborting switch", ...)
c.setVoiceState(vs.ChannelID, vs.JoinedAt)
h.pubsub.Subscribe(c, VoiceTopic(vs.ChannelID))
h.updateKeyHolder(vs.ChannelID)
c.sendMsg(buildErrorMsg(ErrCodeInternal, "voice channel switch failed — please try again"))
return // <-- no broadcastVoiceEvent(ctx, vs.ChannelID, buildVoiceState(*vs))
}
voice_leave.go:96-99 (the leaver is always in the voice_leave audience)
if _, ok := seen[c.userID]; !ok {
audience = append(audience, c.userID)
}
h.broadcastChannelScopedTo(oldChID, buildVoiceLeave(oldChID, c.userID), audience, "voice event")
voice_leave.go:129-134 (an empty join token makes the delete a silent no-op, guaranteeing the abort branch)
if joinToken == "" {
slog.Warn("LeaveVoiceChannelIfMatch skipped due to missing join token", ...)
return nil
}
Client/src/lib/dispatcher.ts:790-815 (a self voice_leave tears the session down)
const shouldTeardownSession = isSelf && voiceStore.getState().currentChannelId === payload.channel_id;
... if (shouldTeardownSession) void leaveVoice(false);
if (isSelf) { leaveVoiceChannel(); }
Suggested fix: In the abort branch (voice_join.go:165-179), stop restoring the session: the voice_leave already broadcast has made every client (and the user's own media session) treat the user as departed, so restoring resurrects a session that no longer exists anywhere else. Delete the c.setVoiceState/Subscribe/updateKeyHolder restore and just send the error — with the client state left cleared, sweepStaleVoiceStates' DB loop (row present, voiceChID=0 mismatch) removes the stale row within one tick and re-broadcasts voice_leave, and the user_id-PK upsert lets the user rejoin immediately. If the restore must stay for some reason, the alternative is to add h.broadcastVoiceEvent(ctx, vs.ChannelID, buildVoiceState(*vs)) after updateKeyHolder so clients re-add the participant.
Fixed: 7be9ccd2 · test Server/ws/voice_handlers_test.go · revert-proof pass
OC-0035 — medium — Deleting a voice channel races a concurrent voice_join, producing a permanent hub/SFU ghost participant that no sweep can ever detect or heal
Server/admin/handlers_channels.go:288 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-ws
handleDeleteChannel evicts the CURRENT voice participants via hub.CleanupVoiceForChannel(id) and only afterward calls database.AdminDeleteChannel(id), which deletes the channel row and relies purely on the voice_states FK cascade to clean up (Server/db/admin_queries.go:192-196 — a plain DELETE FROM channels, no check for live voice participants). Nothing marks the channel as going away between those two calls (contrast with the archive path in the same file, lines 257-269, which sets Archived=true in the DB before calling CleanupVoiceForChannel, so a racing voice_join sees ch.Archived==true and is refused — voice_join.go:92). The delete path has no such guard, so a voice_join that reads the still-live channel row via GetChannel (voice_join.go:70) during this window proceeds to insert a voice_states row and set the hub client's in-memory voice state (voice_join.go:222, c.setVoiceState — done deliberately before the LiveKit token round trip per the BUG-088 comment) exactly as it would for a channel that isn't being deleted. If AdminDeleteChannel's cascade fires after that insert commits, the freshly-created voice_states row is silently deleted by the cascade, but the hub client's in-memory voiceChID, its VoiceTopic subscription, and (if the channel had capacity) the LiveKit room are left completely untouched — nothing in handleVoiceJoin re-checks that the channel still exists after the insert. The resulting ghost is then invisible to both of sweepStaleVoiceStates's healing loops (Server/ws/hub_sweep.go:140-250): the DB-driven loop (lines 193-249) only iterates rows returned by GetAllVoiceStates, and the cascade-deleted row is no longer among them, so it can never flag a hub client with no matching DB row; the permission-revocation loop (lines 152-191) calls hasChannelPermChecked, whose GetChannelPermissions query (Server/db/channel_queries.go:141-153) returns (0,0,nil) — not an error — for a nonexistent channel ID (it's a plain lookup keyed by channel_id with sql.ErrNoRows mapped to a clean zero), so the effective-permission check collapses to the user's bare role bits; any role whose base permissions include CONNECT_VOICE (the common default) is reported 'allowed' and never evicted. The user is left stuck 'in voice' forever (mic hot if publishing, SFU room orphaned) with the client UI showing nothing to leave from (client-side channel_delete handling in dispatcher.ts:617-633 only redirects the sidebar/active-channel view; it performs no voice teardown at all), until they manually reconnect the whole client.
Repro: 1) Create a voice channel with at least one connected participant so cleanup takes real wall-clock time (CleanupVoiceForChannel's per-participant LiveKit RemoveParticipant call can take up to lkTimeout=5s each — livekit.go:153,181). 2) As an admin, DELETE that channel via the admin API/UI. 3) While CleanupVoiceForChannel is still evicting the existing participants (i.e., before AdminDeleteChannel's DELETE has executed), have a different, already-connected user send voice_join for that same channel_id — it passes GetChannel, permission, and archived checks (all still see the live row) and its JoinVoiceChannel/JoinVoiceChannelIfCapacity insert commits before the channel row is deleted. 4) AdminDeleteChannel then runs, cascading away the new voice_states row along with the channel. 5) Observe: the joining client's hub-side voiceChID stays set to the deleted channel, its VoiceTopic subscription and (if configured) LiveKit SFU membership are never torn down, and it is never picked up by either loop of sweepStaleVoiceStates on any subsequent tick — it stays a permanent ghost until that client's socket disconnects on its own.
Suggested fix: Mirror the archive path's guard: in handleDeleteChannel, persist archived=1 on the channel (e.g. via AdminUpdateChannel with Archived:true, or a dedicated UPDATE) BEFORE calling hub.CleanupVoiceForChannel, so any voice_join racing the cleanup is refused by the existing archived gate at voice_join.go:92; then delete the row as today. (Defense-in-depth alternative: in handleVoiceJoin, re-fetch GetChannel after the voice_states insert commits and call rollbackVoiceJoin if the channel is gone or archived.)
Fixed: 7be9ccd2 · test Server/admin/api_test.go · revert-proof pass
OC-0036 — medium — Slow mode consumes its cooldown token before content and attachment validation, so a rejected send locks the composer for the full window
Server/service/message_crud.go:64 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-service
SendMessage calls limiter.Allow on the per-(user, channel) slow-mode key — which records a timestamp — before sanitizeContent, before the ATTACH_FILES check, and before the insert. Any of those can reject the send, but the cooldown has already been spent, so the user is refused with SLOW_MODE for up to maxSlowModeSeconds (21600 s = 6 h) without ever having posted anything.
Repro: Set slow_mode = 3600 on #general. As a member without MANAGE_MESSAGES, send a 5000-rune chat_send. Line 66 records the slow-mode timestamp, then sanitizeContent (message.go:216) returns ErrBadRequest "message content exceeds maximum length" — nothing is stored and nothing is broadcast. Shorten the text and resend immediately: line 66 now returns false and the send is refused with ErrSlowMode for the next hour. The same holds for an attachment-only send by a user lacking ATTACH_FILES (rejected at line 80) and for a CreateMessageWithMentions failure at line 93. No test locks the current ordering — Server/ws/coverage_chat_test.go:241 only asserts that a second successful send is throttled.
Evidence: Server/service/message_crud.go:63-82 — the Allow (which records the timestamp) precedes both validations:
// Slow mode (non-DM only).
if !isDM && ch.SlowMode > 0 && !s.perms.HasChannelPerm(ctx, p.UserID, p.ChannelID, permissions.ManageMessages) {
slowKey := auth.Key(auth.Key("slow", p.UserID), p.ChannelID)
if s.limiter != nil && !s.limiter.Allow(slowKey, 1, time.Duration(ch.SlowMode)*time.Second) {
return nil, fmt.Errorf("%w: channel has %ds slow mode", ErrSlowMode, ch.SlowMode)
}
}
Server/auth/ratelimit.go:149-154 — Allow appends the timestamp on the permitted path, so the token is spent even though the caller then errors out:
if len(e.timestamps) >= limit { return false }
e.timestamps = append(e.timestamps, now)
return true
Server/ws/command.go:401-441 — the chat_send constructor validates only channel_id and the attachment count/length; content length and emptiness are never checked before the service call, so an over-length body reaches line 72.
Server/admin/handlers_channels.go:147 — maxSlowModeSeconds = 21600.
Suggested fix: Move the slow-mode block (message_crud.go:63-69) below the sanitizeContent call and the ATTACH_FILES permission check (i.e., to just after line 82, before resolveMentions), so the once-per-window token is only consumed once the send has passed every request-shaped validation.
Fixed: 7be9ccd2 · test Server/service/message_crud_test.go · revert-proof pass
Client/src/main.ts:251 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-client-tauri-client-src
The status-change listener only puts a presence_update on the wire. Unlike both in-app status surfaces (UserBar.ts:150-157 and settings/AccountTab.ts:883-884, which call saveUserStatus(status) and applyPresence(status)), it never writes the userStatus preference and never calls updatePresence(). lib/userStatus.ts is documented as "the single client-side source of truth" for the chosen status, and three separate consumers read it — so after a tray selection the server and the client disagree permanently, and two independent code paths then silently undo the user's choice.
Repro: Pick "Do Not Disturb" from the tray Status submenu while signed in. The server stores dnd and every other client sees dnd, but loadUserStatus() still returns "online" with origin "manual". Consequences, all reachable: (1) every incoming message still raises a desktop notification and plays the chime, because notifications.ts:85 gates on loadUserStatus() === "dnd" — DND set from the tray does nothing it promises; (2) after ten quiet minutes autoIdle's apply(true) computes nextAutoStatus("online", "manual", true) === "idle" and sends presence_update {status:"idle"}, overwriting the DND — the module's own doc comment states "a manually chosen Do Not Disturb or Invisible is never touched"; (3) on the next WS reconnect, auth_ok carries the user's own true status ("dnd", serve_ready.go:45), so restoreSavedPresence() sees serverStatus "dnd" != loadUserStatus() "online" and sends presence_update {status:"online"} plus a local updatePresence(online), silently reverting the tray choice; (4) the UserBar dot and the settings Account tab keep rendering the pre-tray status for the whole session, because both re-render off onUserStatusChange, which only fires from saveUserStatus.
Evidence: main.ts:251-256
void listen("status-change", (e) => {
const status = e.payload;
if (status === "online" || status === "idle" || status === "dnd" || status === "offline") {
ws.send({ type: "presence_update", payload: { status } });
}
});
contrast — UserBar.ts:150-157
onStatusChange: (status: UserStatus) => {
saveUserStatus(status);
updateFromState();
... ws.send({ type: "presence_update", payload: { status } })
}
consumers of the pref the tray path never writes:
lib/notifications.ts:85 const dnd = loadUserStatus() === "dnd";
lib/autoIdle.ts:374 const next = nextAutoStatus(loadUserStatus(), loadUserStatusOrigin(), idle);
pages/MainPage.ts:180-191 restoreSavedPresence(): compares loadUserStatus() with authStore.user.status and re-sends the local value on every transition to "connected"
Suggested fix: In the main.ts status-change listener, mirror UserBar's path instead of raw-sending: map the tray's legacy "offline" to "invisible" (matching userStatus.ts's migration), call saveUserStatus(mapped) (origin "manual") before ws.send({type:"presence_update",payload:{status: mapped}}). Persisting via saveUserStatus makes notifications, autoIdle, restoreSavedPresence, and the UserBar/Account-tab renders (via onUserStatusChange) all agree with the wire state in one place.
Fixed: 8787b906 · test Client/tests/unit/main.test.ts · revert-proof pass
OC-0038 — medium — The LiveKit participant_left teardown never tells the leaver, unlike every sibling eviction path
Server/ws/livekit_webhook.go:229 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-ws
handleWebhookParticipantLeft clears the client's voice state, drops its VoiceTopic subscription, deletes the DB row and re-elects the key holder, then announces the departure with the plain broadcastVoiceEvent. That helper's audience is (READ_MESSAGES holders) ∪ (clients whose getVoiceChID() still equals the channel) — and the leaver was just removed from the second set. Voice membership is gated on CONNECT_VOICE alone, so a participant without READ_MESSAGES on that voice channel receives nothing. The two sibling teardown paths, finishVoiceLeave (voice_leave.go:96-98) and CleanupVoiceForChannel (hub_sweep.go:337-342), both explicitly append the evicted user to the audience for exactly this reason; this path does not, leaving the client believing it is still in a call the server has already torn down.
Repro: 1. Configure voice channel V so role R has CONNECT_VOICE but a channel_overrides deny on READ_MESSAGES (the configuration the code repeatedly documents as supported — see the audience comments in voice_leave.go:74-82 and hub_broadcast.go:61-66). 2. User U (role R) joins V: voice_states row committed, c.voiceChID=V, c.voiceJoinToken=JoinedAt, subscribed to VoiceTopic(V). 3. U's SFU connection drops (network blip, media-port loss) while the WebSocket stays up; LiveKit fires participant_left with identity "user-U:JoinedAt" for room "channel-V". 4. matched is true, so the server clears c.voiceChID/voiceJoinToken/e2eePubKey, unsubscribes VoiceTopic(V), deletes the voice_states row, and re-elects the key holder. 5. broadcastVoiceEvent resolves the audience: channelReadAudience takes the non-DM role-scan branch and excludes U (no READ_MESSAGES); the participant union cannot see U because step 4 already zeroed getVoiceChID(). U receives no voice_leave. 6. U's client still renders itself in the call with the mic hot and keeps auto-reconnecting to LiveKit with a token whose voice_states row no longer exists — every retry is ejected by handleWebhookParticipantJoined's rogue-participant check (livekit_webhook.go:132), and nothing on U's socket ever reports the eviction.
Evidence: c.voiceMu.Lock()
matched := c.voiceChID == channelID && c.voiceJoinToken != "" && c.voiceJoinToken == joinToken
if matched {
c.voiceChID = 0
...
}
c.voiceMu.Unlock()
if matched {
h.pubsub.Unsubscribe(c, VoiceTopic(channelID))
...
h.broadcastVoiceEvent(ctx, channelID, buildVoiceLeave(channelID, userID))
// hub_broadcast.go:67-79 — broadcastVoiceEvent's audience, with no leaver term:
audience := h.channelReadAudience(ctx, channelID)
...
for uid, c := range h.clients {
if _, ok := seen[uid]; !ok && c.getVoiceChID() == channelID {
audience = append(audience, uid)
}
}
// voice_leave.go:96-98 — the sibling path that DOES include the leaver:
if _, ok := seen[c.userID]; !ok {
audience = append(audience, c.userID)
}
Suggested fix: Extract finishVoiceLeave's audience construction (voice_leave.go:83-99: channelReadAudience ∪ remaining participants ∪ the leaver) into a shared helper, and call it from handleWebhookParticipantLeft's matched branch in place of the bare h.broadcastVoiceEvent(ctx, channelID, buildVoiceLeave(channelID, userID)) at livekit_webhook.go:229.
Fixed: 7be9ccd2 · test Server/ws/livekit_test.go + Server/ws/livekit_webhook_joined_test.go · revert-proof pass
OC-0040 — medium — Scroll-to-bottom button and "Jump to Present" pill are absolutely positioned inside the scroll container, so they scroll out of view exactly when they are shown
Client/src/components/MessageList.ts:810 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
Both controls are appended to root (.messages-container), which is itself the overflow-y: auto scroller, and are styled position: absolute; bottom: 8px. Per CSS Overflow, boxes whose containing block is the scroll container are part of its scrollable overflow region, so they translate with the scrolled content: the button sits at the viewport bottom only at scrollTop ≈ 0 and is painted scrollTop px above the visible area otherwise. Both controls are only made visible when the user is NOT at the bottom, i.e. precisely when scrollTop is large and they are off-screen.
Repro: Open a channel with ~5000px of content. Scroll up 2000px: updateScrollToBottomBtn() adds .visible (opacity 1, pointer-events auto) but the button's painted position is (clientHeight - 48) - 2000 px, far above the scrollport, clipped away by the container's overflow/contain: strict — the user has a "visible" control they can neither see nor click. Same for the pill: jump to an old message via scrollToMessage (which sets root.scrollTop = offsetBefore(idx)), updateJumpToPresentPill() adds .visible, and the only signal that the loaded window is detached from the live tail is painted off-screen. jsdom has no layout, so tests/unit/message-jump.test.ts:636-677 assert only the class, not visibility.
Evidence: root.appendChild(scrollToBottomBtn);
root.appendChild(jumpToPresentPill);
// src/styles/app.css:896 .messages-container { flex:1; overflow-y:auto; contain:strict; position:relative; }
// src/styles/app.css:913 .scroll-to-bottom-btn { position:absolute; bottom:8px; right:16px; … }
// src/styles/app.css:948 .jump-to-present-pill { position:absolute; bottom:8px; left:50%; … }
Suggested fix: Give the controls a non-scrolling positioned ancestor: in mount(), wrap the scroller in a position:relative wrapper div and append scrollToBottomBtn and jumpToPresentPill to the wrapper instead of root (root keeps the scroll listener and children; the wrapper becomes what is appended to parentContainer).
Fixed: b1fb565 · test Client/tests/unit/message-list.test.ts · revert-proof self-reported
OC-0041 — medium — Any user whose username is exactly "System" has every message rendered as a server system notice, with no author and no moderation controls
Client/src/components/message-list/renderers.ts:182 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
renderMessage dispatches to renderSystemMessage purely on msg.user.username === "System" — it never checks user.id (the tests use id 0) and the server never emits such messages, so the only way to reach that branch in production is a real account named "System". Server-side ValidateUsername (Server/auth/helpers.go:19) only rejects control/invisible characters and the "[deleted-…]" namespace, so the name is registrable.
Repro: Register an account with username "System" and post "Your session was flagged — re-enter your password at …". Every client renders it through renderSystemMessage: a system icon, muted italic text and a timestamp, with no avatar, no author name and no role colour — visually identical to a server notice. Because renderSystemMessage returns before the hover action bar is built, the row also carries no react/reply/pin/edit/delete buttons and there is no message context menu anywhere in the client, so a moderator with canManageMessages() has no UI path to delete it.
Evidence: export function renderMessage(msg, isGrouped, allMessages, opts, signal) {
if (msg.user.username === "System") {
return renderSystemMessage(msg);
}
// renderSystemMessage builds only icon + text + time and returns — the
// if (!msg.deleted && msg.status === "sent") action-bar block is unreachable.
Suggested fix: Reserve the name server-side in auth.ValidateUsername: reject strings.EqualFold(strings.TrimSpace(username), "System") alongside the existing "[deleted-" reservation (covers both register and rename since both funnel through it). Per docs/security.md, route the fix through a GitHub Security Advisory rather than a public issue.
Fixed: 7be9ccd2 · test Client/tests/unit/renderers.test.ts · revert-proof pass
OC-0042 — medium — leaveVoice() stops manual camera/screen tracks without bumping the enable/disable race-guard generation, so a camera/screenshare enable that is mid-flight (awaiting the OS permission prompt / device picker) when the user leaves voice resurrects a track after the room is gone
Client/src/lib/livekitSession.ts:1361 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-client-tauri-client-src
The generation counter on CameraTrackState/ScreenTrackState exists specifically so a concurrent enable() that captured its value before a multi-second device-acquisition await (getUserMedia/getDisplayMedia) can detect it was superseded by a disable and discard the track instead of publishing over it (see the doc comment at screenShare.ts:152-159). doDisableCamera/doDisableScreenshare (screenShare.ts:276, screenShare.ts:399) correctly call bumpGeneration(state) before stopping tracks. leaveVoice() performs the exact same operation — it calls stopManualCameraTrack(this._cameraState, this._room) and stopManualScreenTracks(this._screenState, this._room) directly (lines 1361-1362) and even resets setLocalCamera(false)/setLocalScreenshare(false) (lines 1386-1387) — but never touches state.generation. Because _cameraState/_screenState are single per-session fields never reinitialized across join/leave cycles (declared once at lines 259-260), a stale enableCamera()/enableScreenshare() continuation that resumes after leaveVoice() ran will pass the (state.generation ?? 0) !== generation check at screenShare.ts:235/334, believe it is still current, set state.manualCameraTrack/manualScreenTracks to the newly created track(s), and attempt room.localParticipant.publishTrack() against room — a reference captured before the leave, i.e. a room that leaveVoice() has already called room.disconnect() on (line 1373). If that publish does not synchronously throw, the store is left saying camera/screenshare is whatever enableCamera set (or, worse, a mismatched state: the track object sits in state.manualCameraTrack referencing a track published to an already-disconnected room), and the physical camera/mic-capture device stays open. Nothing frees it: the next enableCamera()/disableCamera() call only calls stopManualCameraTrack when deps.getRoom() is non-null (screenShare.ts:206, :220), i.e. only once the user has rejoined a voice channel — until then the camera hardware (LED) stays active after the user has already left the call.
Repro: 1) Join a voice channel (room R1 live). 2) Click 'Enable camera' — enableCamera() runs setLocalCamera(true), captures room=R1 and generation=0, then awaits createLocalVideoTrack(...), which blocks on the browser's camera permission prompt. 3) Before responding to the prompt, click 'Leave Voice' — leaveVoice() runs synchronously: stopManualCameraTrack no-ops (nothing published yet), room.disconnect() is called on R1, setLocalCamera(false) is set, generation stays 0. 4) Grant camera permission — createLocalVideoTrack resolves; enableCamera() checks (state.generation ?? 0) !== generation → 0 !== 0 → false (not superseded), sets state.manualCameraTrack = videoTrack, and calls room.localParticipant.publishTrack(videoTrack, ...) on the already-disconnected R1. The camera device is now held open by a track that was never cleaned up, and the app has already visually left the voice call.
Suggested fix: Export a supersede helper from screenShare.ts (e.g. export function supersedeVideoEnable(state: GenerationGuarded): void { state.generation = (state.generation ?? 0) + 1; } — reuse it inside bumpGeneration) and call it on this._cameraState and this._screenState in leaveVoice immediately before the stopManualCameraTrack/stopManualScreenTracks calls at livekitSession.ts:1361-1362, so the stale enable discards its track at the existing screenShare.ts:235/334 check.
Fixed: 7be9ccd2 · test Client/tests/unit/livekit-session.test.ts · revert-proof pass
OC-0043 — medium — The built-in "light" theme overrides only 4 of the ~45 design tokens and has no stylesheet, so the message composer and every form input render near-invisible dark-on-dark
Client/src/components/settings/helpers.ts:37 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
applyThemeByName applies built-in themes by adding a body.theme-<name> class, but src/styles/ contains a rule for body.theme-neon-glow only — there is no body.theme-light (or body.theme-midnight) block anywhere. The entire "light" theme is therefore the 4 inline custom properties applyTheme writes onto document.documentElement. --text-normal flips to the dark #313338 while --bg-input keeps the dark-theme #383a40 from tokens.css:11, so every surface painted with var(--bg-input) ends up carrying dark text on a dark box (contrast ≈1.1:1).
Repro: Settings -> Appearance -> click "Light". applyTheme("light") sets --bg-primary:#ffffff and --text-normal:#313338 on <html>; --bg-input stays #383a40. The chat composer (.message-input-box + .msg-textarea) now paints #313338 glyphs on a #383a40 background — typed text is unreadable. Same for the login form's Server Address / Username / Password fields (login.css:586) and the reply bar. The setting persists (applyStoredAppearance re-runs applyTheme on startup), so the state survives restart.
Evidence: helpers.ts:37-42 — light: { "--bg-primary": "#ffffff", "--bg-secondary": "#f2f3f5", "--bg-tertiary": "#e3e5e8", "--text-normal": "#313338" } (4 keys, applied via applyTheme -> root.style.setProperty)
themes.ts:61-62 — if (BUILT_IN_THEMES.includes(name)) { document.body.classList.add(theme-${name}); } // no CSS backs theme-light / theme-midnight
grep -rn "theme-light\|theme-midnight" src/styles/ -> no matches; only theme-neon-glow.css:4 body.theme-neon-glow { ... }
tokens.css:11 — --bg-input: #383a40; (never overridden by the light map)
app.css:2342 — .message-input-box { background: var(--bg-input); }
app.css:2377-2380 — .msg-textarea { background: transparent; color: var(--text-normal); }
login.css:586-590 — .form-input { background: var(--bg-input); color: var(--text-normal); }
app.css:2173-2183 — .reply-bar-inner { background: var(--bg-input); } / .reply-bar-inner strong { color: var(--text-normal); }
Suggested fix: Give the light theme a complete palette: add a body.theme-light block (new theme-light.css, mirroring theme-neon-glow.css) that overrides every dark token used against --text-normal — at minimum --bg-input, --bg-hover, --bg-active, --bg-modifier-, --border, --border-strong, --text-muted, --text-faint, --text-micro, --header-primary, --header-secondary, --interactive- — with light-mode values. (Extending the THEMES.light map works too, but the CSS block matches how neon-glow already ships its extra tokens.)
Fixed: 7be9ccd2 · test Client/tests/unit/settings-helpers.test.ts · revert-proof pass
OC-0044 — medium — rollbackVoiceJoin deletes voice_states by userID alone, letting a stale/failed join's rollback destroy a concurrently-established newer voice membership
Server/ws/voice_join.go:464 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-ws
rollbackVoiceJoin (called from handleVoiceJoin's two failure paths at lines 208 and 300, both after the DB row for channelID has already been inserted and, on the second path, after c.setVoiceState has already been applied) unconditionally clears c's in-memory voice channel via c.clearVoiceChID() and deletes the user's voice_states row via h.db.LeaveVoiceChannel(context.WithoutCancel(ctx), c.userID) — a plain DELETE FROM voice_states WHERE user_id = ? with no channel_id/joined_at condition. Every sibling leave path in this same package (finishVoiceLeave -> leaveVoiceChannelWithRetry, handleVoiceLeaveIfStillIn) instead uses LeaveVoiceChannelIfMatch(userID, expectedChannelID, expectedJoinedAt) specifically, per that function's own comment, 'to prevent a race where a delayed retry could wipe a newer voice membership.' rollbackVoiceJoin never received that same protection, despite its own comment acknowledging that a dying connection ('the join failed BECAUSE the connection died — that cancellation is the most common rollback trigger') is its main trigger, which is exactly the scenario where a second, independent connection for the same user can already have re-established a legitimate new voice_states row by the time this delayed rollback runs (context.WithoutCancel is used precisely so the delete keeps running after the original connection and its context are gone).
Repro: User A's connection c1 sends voice_join for channel X; the DB insert for X succeeds and (on the token-generation failure path, after line 222) c1.setVoiceState(X, joinedAt) has already run. Before GenerateToken/GetVoiceState-verify on c1 completes, the underlying connection drops (network blip); c1's readPump goroutine is still live and blocked inside handleVoiceJoin. The client immediately opens a new connection c2 for the same user; the server's registerNow (hub.go:399) swaps h.clients[userID] to c2. The user then sends voice_join for channel Y on c2, which succeeds and inserts a fresh voice_states row (Y, newJoinedAt), with c2 now subscribed to VoiceTopic(Y) and live in the LiveKit room. Meanwhile c1's stalled call finally errors (GetVoiceState fails at voice_join.go:205-211, or GenerateToken fails at voice_join.go:297-303), so rollbackVoiceJoin(ctx, c1, X, false) runs and executes h.db.LeaveVoiceChannel(context.WithoutCancel(ctx), userID) — deleting the voice_states row for Y that c2 legitimately just created. Result: c2 is still marked in-memory as voiceChID=Y, still subscribed to the voice topic, and still present in the LiveKit room, but its DB voice_states row is gone — a permanent DB/hub/SFU desync (missing from GetChannelVoiceStates, ready resyncs, and channel-capacity counts for Y) that nothing subsequently repairs, matching the same ghost-state class as the already-known CleanupVoiceForChannel non-atomicity bug but triggered from the opposite (failed-join rollback) direction.
Suggested fix: Scope the compensating delete to the join instance it is undoing: thread the join's identity into rollbackVoiceJoin (pass state.JoinedAt at the voice_join.go:300 call site; at the :208 site, where GetVoiceState failed, re-read the row with context.WithoutCancel and proceed only if it still names channelID) and replace h.db.LeaveVoiceChannel(ctx, c.userID) with h.db.LeaveVoiceChannelIfMatch(ctx, c.userID, channelID, joinedAt), mirroring leaveVoiceChannelWithRetry.
Fixed: db0275a2 · test Server/ws/coverage_voice_lifecycle_test.go · revert-proof pass
OC-0045 — medium — Role demotion's live-subscription revocation is gated on a cosmetic role re-read, so a failed lookup leaves the demoted user subscribed to channels they can no longer read
Server/admin/handlers_users.go:192 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-service
After ChangeUserRole commits, the only call that revokes the user's live pub/sub subscriptions (hub.BroadcastMemberUpdate -> revokeUnreadableChannels) and the only call that re-derives visibility (hub.RefreshAllChannelVisibility) are both nested inside if role, err := database.GetRoleByID(...); err == nil && role != nil, a lookup whose only real product is the role NAME for the member_update payload. When that read fails or returns nil the demotion is committed and the permission cache is invalidated, but the socket keeps every ChannelTopic subscription its old role earned — and READ_MESSAGES is only ever checked at channel_focus, never again on the delivery path.
Repro: User U holds Moderator, which has a channel_overrides ALLOW of READ_MESSAGES on private #staff; U has #staff focused, so ws/handlers.go:170 has subscribed the socket to ChannelTopic(#staff). Admin A demotes U to a non-default role R via PATCH /admin/api/users/{U} {"role_id": R}. ModerationService.ChangeUserRole commits and InvalidateUser(U) runs. Admin B now deletes role R (DELETE /admin/api/roles/{R}) in the window before A's handler reaches line 192 — or the single-writer SQLite pool returns SQLITE_BUSY for that one read. GetRoleByID returns (nil, nil) or (nil, err), so the whole block is skipped: no member_update, no revokeUnreadableChannels, no RefreshAllChannelVisibility, no bumpVisibilityWatermark. U's socket stays in ChannelTopic(#staff) and keeps receiving every chat_message, chat_edited, chat_deleted and reaction_update posted in #staff for the entire life of the connection; every other client also still renders U as Moderator. The sibling handlers handleDeleteRole (handlers_roles.go:206-208) and handlePatchRole's permsChanged branch (handlers_roles.go:160-174) both run the identical fan-out unconditionally.
Evidence: if permInvalidator != nil {
permInvalidator.InvalidateUser(id)
}
if role, err := database.GetRoleByID(r.Context(), *req.RoleID); err == nil && role != nil {
if hub != nil {
hub.BroadcastMemberUpdate(id, role.Name)
hub.RefreshAllChannelVisibility()
}
}
// hub_broadcast.go:470 — the only caller of the revocation routine
func (h *Hub) BroadcastMemberUpdate(userID int64, roleName string) {
h.BroadcastToAll(buildMemberUpdate(userID, roleName))
h.revokeUnreadableChannels(userID)
}
Suggested fix: Decouple the fan-out from the name lookup: have ModerationService.ChangeUserRole return the *db.Role it already loads at moderation.go:159, then in handlePatchUser run hub.BroadcastMemberUpdate(id, role.Name) and hub.RefreshAllChannelVisibility() unconditionally (when hub != nil), deleting the GetRoleByID re-read entirely.
Fixed: 8579cb5d · test Server/admin/handlers_users_broadcast_test.go · revert-proof pass
OC-0046 — medium — The Font Size slider and the "Large Font" accessibility toggle are no-ops — --font-size is written but no stylesheet ever reads it
Client/src/styles/base.css:22 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
Three separate code paths write the --font-size custom property (applyStoredAppearance at startup, the Appearance-tab slider on input, and the .large-font rule in app.css), but var(--font-size) appears nowhere in the repository. base.css:22 sets body { font-size: 14px } as a hard literal, and every other rule uses the fixed --font-size-xxs … --font-size-xxl scale from tokens.css. The token is a dead end, so both user-facing font-size controls change persisted state and nothing else.
Repro: Open Settings -> Appearance and drag the Font Size slider from 16 to 20. document.documentElement.style.getPropertyValue("--font-size") becomes "20px" and localStorage records owncord:settings:fontSize = 20, but no text in the app changes size — the computed font-size of body stays 14px from base.css:22 and every component stays on the fixed --font-size-* scale. Identically, Settings -> Accessibility -> "Large Font" adds the large-font class to <html>, whose only declaration is --font-size: 18px, and nothing renders larger. The existing tests (tests/unit/settings-overlay.test.ts:189, tests/unit/accessibility-tab.test.ts:321) assert only that the property/class is set, never that a rendered size changes, so they pass while the feature does nothing. Note also that the default pref is 16px while base.css hard-codes 14px, so the two sources already disagree.
Evidence: src/styles/base.css:22 -> body { font-family: var(--font-body); font-size: 14px; ... }
src/styles/app.css:5211-5213 -> .large-font { --font-size: 18px; }
src/lib/appearance.ts:36-39 -> document.documentElement.style.setProperty("--font-size", ${loadPref<number>("fontSize", 16)}px);
src/components/settings/AppearanceTab.ts:100 -> document.documentElement.style.setProperty("--font-size", ${size}px);
src/components/settings/AccessibilityTab.ts:57 -> document.documentElement.classList.toggle("large-font", nowOn);
Verification: grep -rn "var(--font-size)" . --include=*.css --include=*.html --include=*.ts (excluding node_modules) returns zero hits. tokens.css defines only --font-size-xxs/xs/sm/md/lg/xl/xxl, never --font-size.
Suggested fix: Make the variable actually feed the type scale: in tokens.css derive the scale from it (e.g. --font-size-md: var(--font-size, 14px) and the other steps via calc() multipliers of --font-size), and change base.css:22 to font-size: var(--font-size-md). Align the appearance.ts default (16) with the actual base (14) so the slider's initial position matches what is rendered.
Fixed: 7be9ccd2 · test Client/tests/unit/base-font-size-css.test.ts · revert-proof pass
OC-0047 — medium — Attachment/avatar fetches lose their bearer token and their cert-pinned proxy when the server host is stored with an explicit :443
Client/src/components/message-list/attachments.ts:158 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-client-tauri-client-src
isServerUrl compares new URL(url).host against the raw _serverHost string without the ":443"-stripping normalization the rest of the client applies (normalizeHostForCertCompare in lib/ws.ts, cert_store_key in src-tauri/src/tofu.rs). WHATWG URL drops the default port for https:, so a host stored as example.com:443 never matches, fetchServerFile takes the "external host" branch, and every server file is fetched with no Authorization header and outside the TOFU-pinned loopback proxy.
Repro: 1. Add/enter the server host as chat.example.com:443 (accepted: isValidHost in lib/api.ts:82 is /^[\w.-]+(:\d+)?$/; ServerPanel.ts:310 uses the same regex).
2. Log in. MainPage.ts:96 calls setServerHost("chat.example.com:443").
3. Open any channel with an image attachment, or any user with an uploaded avatar. resolveServerUrl("/api/v1/files/abc") yields https://chat.example.com:443/api/v1/files/abc; new URL(...).host is "chat.example.com" (default port dropped) which !== "chat.example.com:443".
4. fetchServerFile therefore returns tauriFetch(url) with no Authorization header and bypassing ensureHttpProxy. The server's AuthMiddleware answers 401, res.ok is false, fetchImageAsDataUrl returns null — every attachment image, custom emoji and uploaded avatar silently falls back to its placeholder for the whole session. On a self-signed deployment the direct https fetch also fails TLS in the webview, which is the exact failure the TOFU proxy exists to avoid.
Same root cause makes isTrustedServerUrl (attachments.ts:169) return false, so embeds.ts:135's trusted-server exemption stops applying and link previews to a LAN-hosted OwnCord server are blocked as SSRF.
Existing tests only cover the port-less form (tests/unit/attachments-auth.test.ts:63 setServerHost("chat.example.com")), so nothing locks the current behavior.
Evidence: attachments.ts:36-48,152-186
export function setServerHost(host: string): void { _serverHost = host.toLowerCase(); } // no ":443" strip
export function resolveServerUrl(url) { ... return https://${_serverHost}${url}; }
function isServerUrl(url: string): boolean {
if (_serverHost === null) return false;
try { const parsed = new URL(url); return parsed.host === _serverHost; } catch { return false; }
}
async function fetchServerFile(url: string): Promise {
if (!isServerUrl(url)) return tauriFetch(url); // <- no token, no TOFU proxy
...
headers["Authorization"] = Bearer ${token};
return tauriFetch(${origin}${parsed.pathname}${parsed.search}, { headers });
}
Contrast — lib/ws.ts:120-127 documents that config hosts are stored verbatim in this exact shape:
/** ... Profile/config hosts are stored verbatim (e.g. "Example.COM:443"), but the proxies
- always emit the normalized (stripped, lowercased) form ... */
export function normalizeHostForCertCompare(host: string): string {
return host.replace(/:443$/, "").toLowerCase();
}
Server side, the endpoint is auth-gated — Server/api/upload_handler.go:123
r.With(AuthMiddleware(database)).Get("/api/v1/files/{id}", handleServeFile(...))
Suggested fix: Normalize once at the single entry point: in setServerHost (attachments.ts:37), store _serverHost = host.replace(/:443$/, "").toLowerCase(); (mirroring normalizeHostForCertCompare). resolveServerUrl then emits the port-less form (same effective https origin), isServerUrl's parsed.host comparison matches for both port-less and explicit-:443 input URLs, and ensureHttpProxy(parsed.host) resolves the same TOFU pin because cert_store_key strips :443 anyway. Non-default ports are preserved on both sides.
Fixed: 7be9ccd2 · test Client/tests/unit/attachments-auth.test.ts · revert-proof pass
OC-0048 — medium — Self-account-deletion emits no member_ban: router.go never supplies the optional AuthBroadcaster, so every other client keeps the deleted user and the deleted user's own socket survives
Server/api/router.go:104 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
MountAuthRoutes accepts a variadic AuthBroadcaster that handleDeleteAccount uses to fan out member_ban (and, via Hub.BroadcastMemberBan, to force-disconnect the target). The only production call site omits it because it is mounted at line 104, before the hub exists at line 141 — so ab is always nil and the if broadcaster != nil guard in handleDeleteAccount is never taken in a real server. The event the code was written to send is dead in production; only tests ever pass a broadcaster.
Repro: Users A and B are both connected over WebSocket. B calls DELETE /api/v1/auth/account with the correct password. The row is anonymised + banned and B's DB sessions are revoked (auth_handler.go:560-604), and the handler reaches if broadcaster != nil { broadcaster.BroadcastMemberBan(user.ID) } at auth_handler.go:616 with broadcaster == nil. Result: (1) A's member list, DM sidebar and message authorship keep showing B under B's pre-deletion username indefinitely — the admin ban path (admin/handlers_users.go → hub.BroadcastMemberBan) removes them instantly for the byte-identical DB state; (2) Hub.DisconnectUser (hub_broadcast.go:430) is never called, so B's already-open WebSocket stays live and can keep sending frames until the periodic re-validation fires — SessionCheckInterval = 10 (ws/client.go:21), so up to 9 further messages (chat_message, voice_join, …) are accepted from the deleted, banned account.
Evidence: router.go:104 MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies, totpKey) // no broadcaster
auth_handler.go:91 func MountAuthRoutes(..., broadcaster ...AuthBroadcaster) { var ab AuthBroadcaster; if len(broadcaster) > 0 { ab = broadcaster[0] } ...
auth_handler.go:120 Delete("/account", handleDeleteAccount(database, limiter, ab))
auth_handler.go:616 if broadcaster != nil { broadcaster.BroadcastMemberBan(user.ID) }
ws/hub_broadcast.go:428-431 func (h *Hub) BroadcastMemberBan(userID int64) { h.BroadcastToAll(buildMemberBan(userID)); h.DisconnectUser(userID) }
Grep for MountAuthRoutes shows the only non-test call site is router.go:104; api/auth_handler_delete_broadcast_test.go:54 even labels the no-broadcaster form "the shape every existing MountAuthRoutes call".
Suggested fix: In Server/api/router.go, move the MountAuthRoutes call from line 104 to after hub := ws.NewHub(database, limiter, svc) (line 141) and pass the hub: MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies, totpKey, hub). chi allows route registration in any order before serving, so no other change is needed.
Fixed: 8579cb5d · test Server/api/router_delete_account_broadcast_test.go · revert-proof pass
OC-0049 — medium — High Contrast accessibility toggle's main effect is dead: .high-contrast { --text-normal } is on <html>, which already carries an inline --text-normal written by applyTheme()
Client/src/lib/appearance.ts:21 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
applyStoredAppearance() calls applyTheme(name), which writes the theme's four tokens — including --text-normal — as an inline style on document.documentElement, and then toggles the high-contrast class on the same element. An inline declaration always beats a class rule on the same element, so .high-contrast { --text-normal: #ffffff } (app.css:5204) can never take effect. The toggle's headline promise (pure-white body text) is silently a no-op; only --text-muted and --bg-active, which THEMES does not set inline, actually change.
Repro: Fresh install, no stored theme. main.ts:100 calls applyStoredAppearance(). getActiveThemeName() returns "neon-glow", which is in THEMES, so line 21 runs applyTheme("neon-glow") → helpers.ts:95 sets document.documentElement.style['--text-normal'] = '#dbdee1'. Line 50 then sets document.documentElement.classList.add('high-contrast') when the pref is on. Computed --text-normal on <html> is #dbdee1, not #ffffff — inspect any message body text with High Contrast enabled and it is identical to High Contrast off. Same for every other built-in theme, and re-triggered every time the Appearance tab renders (AppearanceTab.ts:230) or a theme is clicked (AppearanceTab.ts:49). The existing tests (tests/unit/accessibility-tab.test.ts:276, tests/unit/stored-appearance.test.ts:49) only assert the class is toggled, never the resulting token value.
Evidence: lib/appearance.ts:19-24 const activeThemeName = getActiveThemeName(); if (activeThemeName in THEMES) { applyTheme(activeThemeName as ThemeName); }
lib/appearance.ts:49-52 document.documentElement.classList.toggle("high-contrast", loadPref<boolean>("highContrast", false));
components/settings/helpers.ts:93-96 const root = document.documentElement; for (const [key, value] of Object.entries(theme)) { root.style.setProperty(key, value); }
components/settings/helpers.ts:22 / :27 / :34 / :40 every THEMES entry defines "--text-normal"
styles/app.css:5203-5207 .high-contrast { --text-normal: #ffffff; --text-muted: #cccccc; --bg-active: rgba(255,255,255,0.15); }
Suggested fix: In styles/app.css, make the high-contrast tokens important and cover the body-level custom-theme case: .high-contrast, .high-contrast body { --text-normal: #ffffff !important; --text-muted: #cccccc !important; --bg-active: rgba(255,255,255,0.15) !important; } — important author declarations beat normal inline styles, which is exactly the relationship needed here.
Fixed: 7be9ccd2 · test Client/tests/unit/appearance-high-contrast.test.ts · revert-proof pass
OC-0050 — low — CleanupVoiceForChannel's check-then-clear is not atomic, so a concurrent voice_join is silently wiped from the hub while its DB row survives
Server/ws/hub_sweep.go:316 · found 2026-08-12 · hunt general-2026-08-12 · lens ws-hub
The function's own comment claims "the client-state clear [is] conditional on the participant still being in THIS channel: a user who moved to another voice channel between the snapshot above and this loop must not be clobbered". The implementation reads client.getVoiceChID() (one voiceMu acquisition), then calls clearVoiceAndUnsubscribe, whose c.clearVoiceState() (client.go:148) clears unconditionally under a second voiceMu acquisition. Nothing spans the compare and the clear. Every sibling site got this right — sweepStaleVoiceStates uses handleVoiceLeaveIfStillIn → clearVoiceStateIfMatch (client.go:164), and the LiveKit webhook inlines a token-aware compare-and-clear under one voiceMu (livekit_webhook.go:203-211).
Repro: User U is in voice channel A. An admin archives or deletes A, so an HTTP handler goroutine runs CleanupVoiceForChannel(A). At the same moment U sends voice_join for channel W on their readPump goroutine. Interleaving: (1) cleanup reads client.getVoiceChID() == A → passes the guard; (2) U's handleVoiceJoin completes, running c.setVoiceState(W, joinedAt) (voice_join.go:222), subscribing VoiceTopic(W) and broadcasting voice_state for W; (3) cleanup calls clearVoiceAndUnsubscribe(client), which unconditionally zeroes voiceChID/voiceJoinToken/e2eePubKey and returns oldChID=W, then does pubsub.Unsubscribe(client, VoiceTopic(W)). U is now in voice W per voice_states but not per the hub: their VoiceTopic(W) subscription is gone (so every voice_e2ee_announce/offer relay for W is missed), broadcastVoiceEvent's participant union can no longer see them, and within 60s sweepStaleVoiceStates sees c.getVoiceChID()==0 != W, deletes the row, broadcasts voice_leave and removes them from the SFU — silently ejecting them from the call they just joined.
Evidence: 313: h.mu.RLock()
314: client, ok := h.clients[vs.UserID]
315: h.mu.RUnlock()
316: if ok && client.getVoiceChID() == channelID {
317: h.clearVoiceAndUnsubscribe(client) // clearVoiceState() clears unconditionally
318: }
Suggested fix: Replace the check+clear at hub_sweep.go:316-318 with the compare-and-clear primitive under one voiceMu acquisition: if ok { if _, cleared := client.clearVoiceStateIfMatch(channelID); cleared { h.pubsub.Unsubscribe(client, VoiceTopic(channelID)) } } (also clear the E2EE fields, which clearVoiceStateIfMatch already does).
Fixed: 8787b906 · test Server/ws/hub_sweep_test.go · revert-proof pass
OC-0051 — low — handleReconnect returns true after a failed handshake write, so the full disconnect teardown runs twice
Server/ws/serve.go:353 · found 2026-08-12 · hunt general-2026-08-12 · lens ws-hub
unregisterFailedHandshake is documented (serve.go:413-414) as safe because "No readPump ever starts for this connection". That invariant holds on the fresh-connect branch (handleFreshConnect returns an error and ServeWS returns without starting pumps) but is false on the reconnect branch: handleReconnect returns true after calling unregisterFailedHandshake and closing the socket, and ServeWS treats true as success and calls startPumps() (serve.go:69-72). readPump then runs against the closed conn, returns immediately, and its defer executes the whole teardown a second time — because unregisterNow(c) now finds no entry and reports replaced=false (a deliberate distinction locked by hub_sweep_test.go:74). This also doubles the window described in the previous finding.
Repro: A client resumes with last_seq > 0 and replay succeeds, so handleReconnect calls registerNow(c) and then conn.Write(auth_ok) — which fails (peer already gone / write timeout). Path: (1) unregisterFailedHandshake(ctx, c) removes c from h.clients, runs handleVoiceLeave if a voice session was transferred, writes MarkUserDisconnected and broadcasts presence{offline} (serve.go:422-439); (2) handleReconnect returns true; (3) ServeWS calls startPumps(), spawning writePump and running readPump on the closed conn; (4) readPump returns on the first Read error and its defer calls unregisterNow(c) again — c is absent, so replaced=false — and issues a second MarkUserDisconnected plus a second BroadcastToAll(presence offline), which consumes a second hub seq, a second replay-buffer slot and a second persisted event row for a duplicate of an event already sent.
Evidence: serve.go:349-353
if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(...)); err != nil {
h.unregisterFailedHandshake(ctx, c)
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
return true
serve.go:69-72
if lastSeq > 0 {
if hub.handleReconnect(ctx, conn, c, database, lastSeq) {
startPumps()
return
serve.go:413-414 (contradicted invariant)
// ... No readPump ever starts for this connection, ...
Suggested fix: Make the two handshake-write-failure paths in handleReconnect signal 'handled, do not start pumps' — e.g. change its return to (handled, startPumps bool) returning (true, false) there and (true, true) on success, with ServeWS calling startPumps() only when both are true. (Equivalently: drop the unregisterFailedHandshake+Close calls on those two paths and let readPump's defer perform the single teardown, since pumps do start on this branch.)
Fixed: db0275a2 · test Server/ws/serve_reconnect_double_teardown_test.go · revert-proof pass
OC-0052 — low — GET /channels/{id}/pins has no LIMIT and no pin cap; past ~32k pins the endpoint fails permanently
Server/db/message_queries.go:635 · found 2026-08-12 · hunt general-2026-08-12 · lens db-storage
GetPinnedMessages is the only read path into scanAndEnrichMessages with no LIMIT — GetMessagesForAPI, GetMessagesAroundForAPI and both search queries are all clamped to <=100 by the service layer. Nothing caps how many messages may be pinned in a channel either (SetMessagePinned has no count check and, unlike SendMessage/handleReaction, no rate limiter), and the handler hardcodes HasMore: false. Because scanAndEnrichMessages then builds three IN (?,?,...) lists with one bound parameter per returned message, a channel with more pins than SQLite's SQLITE_MAX_VARIABLE_NUMBER (32766) makes the request fail with "too many SQL variables" — the pins endpoint then returns 500 for that channel forever, with no way to unpin through the UI that lists them.
Repro: Any ordinary user, no moderator role required: service/message_query.go:207-214 lets any DM participant pin, so open a 1:1 DM with another user, post N messages, then PUT the pin route once per message. At N >= 32766 pins, GET /api/v1/channels/{dmId}/pins runs GetPinnedMessages, gets 32766 rows, and getReactionsBatch builds an IN list with 32767 bound parameters; SQLite rejects it with "too many SQL variables", scanAndEnrichMessages returns an error, and the endpoint answers 500 on every subsequent call for that channel. Below that threshold the same call still loads and JSON-serialises every pinned message with its reactions, attachments and mentions in one unpaginated response (has_more is hardcoded false, so no client can page past it).
Evidence: db/message_queries.go:636-644
rows, err := d.reader.QueryContext(ctx,
SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, ... FROM messages m JOIN users u ON m.user_id = u.id WHERE m.channel_id = ? AND m.pinned = 1 AND m.deleted = 0 ORDER BY m.id DESC, // <- no LIMIT
channelID,
)
db/message_queries.go:529-537 (one bound parameter per pinned message)
query := fmt.Sprintf(
SELECT r.message_id, r.emoji, COUNT(*) as cnt, ... FROM reactions r WHERE r.message_id IN (%s) GROUP BY ..., placeholders)
args = append([]any{requestingUserID}, args...)
api/channel_handler.go:373
writeJSON(w, http.StatusOK, response{Messages: msgs, HasMore: false})
Suggested fix: Chunk the IN-list batches in the shared enrichment path (getReactionsBatch, getAttachmentsBatch, GetMentionsByMessageIDs) at the existing 500-id chunk size used by auth_queries.go/mention_queries.go — one guard in the shared functions covers every caller; optionally also add a pins-per-channel cap in SetMessagePinned.
Fixed: 8787b906 · test Server/db/message_queries_test.go · revert-proof pass
OC-0053 — low — Quick-switch overlay's teardown guard never fires — an orphaned modal is mounted on document.body after MainPage is destroyed
Client/src/pages/main-page/SidebarArea.ts:722 · found 2026-08-12 · hunt general-2026-08-12 · lens client-state
openQuickSwitch awaits profileManager.loadProfiles() and then checks sidebarWrapper.parentElement === null as its "were we torn down while awaiting?" test. That check can never be true: MainPage tears down by removing an ancestor (root.remove() in MainPage.destroy) and never removes sidebarWrapper from its parent app div, so sidebarWrapper.parentElement stays non-null forever. The overlay is then created and mounted to document.body — outside the removed subtree — after every reference to it (quickSwitchInstance, still null when closeQuickSwitch ran during teardown) is gone.
Repro: 1. Signed in, MainPage mounted. Click the disconnect/switch button in UserBar -> openQuickSwitch() runs and awaits profileManager.loadProfiles() (a Tauri IPC round trip).
2. While that await is pending, the session ends asynchronously — e.g. a REST 401 fires main.ts's onUnauthorized -> clearAuth(), or the server broadcasts server_restart with reason "shutdown" (dispatcher.ts:879 clearAuth("server_shutdown")), or an auth_error/BANNED frame arrives.
3. main.ts's authStore subscriber runs router.navigate("connect") -> renderPage("connect") -> currentPage.destroy() -> MainPage.destroy(). That runs closeQuickSwitch() (no-op: quickSwitchInstance is still null) and then root.remove().
4. loadProfiles() resolves. sidebarWrapper.parentElement is still the app div, so the guard passes. createQuickSwitchOverlay(...).mount(document.body) runs.
Result: a full-screen .quick-switch-backdrop modal plus its document-level keydown listener (QuickSwitchOverlay.ts:167) and focus trap sit on top of the freshly-rendered ConnectPage. Nothing holds a reference to it any more, so nothing can call its destroy(); its own "Switch"/"Add server" buttons call clearAuth() against a session that no longer exists. Fix: use a destroyed flag set from the teardown callback (or document.contains(sidebarWrapper)) instead of parentElement === null.
Evidence: function openQuickSwitch(): void {
if (quickSwitchInstance !== null || openingQuickSwitch) return;
openingQuickSwitch = true;
...
void (async () => {
try {
...
await profileManager.loadProfiles();
...
// Ensure we haven't been cleaned up while awaiting
if (sidebarWrapper.parentElement === null) return; // <-- never true
quickSwitchInstance = createQuickSwitchOverlay({ ... });
quickSwitchInstance.mount(document.body); // <-- escapes the removed subtree
} finally { openingQuickSwitch = false; }
})();
}
// MainPage.ts destroy():
// for (const unsub of unsubscribers) unsub(); // includes closeQuickSwitch() -> no-op, instance is null
// ...
// finally { if (root !== null) { root.remove(); root = null; } } // sidebarWrapper.parentElement is still app
Suggested fix: In createSidebarArea, add let tornDown = false; and change the pushed unsubscriber to unsubscribers.push(() => { tornDown = true; closeQuickSwitch(); });, then replace the dead guard at line 722 with if (tornDown) return;. (A flag beats isConnected, which would change behavior for unit tests that mount into a detached container.)
Fixed: db0275a2 · test Client/tests/unit/sidebar-area.test.ts · revert-proof pass
OC-0054 — low — blocksStore survives clearAuth(), so a previous server's block list can gate DM composers on the next server
Client/src/stores/auth.store.ts:93 · found 2026-08-12 · hunt general-2026-08-12 · lens client-state
clearAuth() deliberately resets voiceStore, messagesStore and channelsStore because their ids are per-server, but leaves blocksStore.blockedByMe untouched. Block state is keyed by user id, which is also only unique per server. The only thing that restates it on the next session is dispatcher.ts's fire-and-forget api.listBlocks(), whose failure is swallowed with a log.warn — so a single failed request leaves the previous server's blocked-user ids applied for the whole new session.
Repro: 1. On server A, block the user whose id is 7 -> setUserBlockedByMe(7, true), blockedByMe = {7}.
2. Log out (UserBar disconnect / Settings logout / quick-switch) -> clearAuth(). blockedByMe is still {7}.
3. Log into server B, where user id 7 is an unrelated person. ready arrives; api.listBlocks() is issued but rejects (transient network blip, 500, or the proxy not yet warm) — the rejection is only logged.
4. Open a 1:1 DM with server B's user 7. ChannelController.ts:412 calls dmComposerBlockReason(blocksStore.getState(), 7), which returns BLOCKED_BY_ME_REASON, so the composer is disabled for the rest of the session with "You've blocked this user. Unblock to send messages." for a user that was never blocked here.
5. MemberList.ts:289 reads the same set (isBlocked = blockedByMe.has(member.id)), so that member's context menu offers "Unblock"; clicking it sends DELETE /blocks/7 to server B.
Evidence: // auth.store.ts clearAuth():
resetVoiceStore();
resetMessagesStore();
resetChannelsStore();
clearNsfwAcknowledgements();
cleanupNotificationAudio();
authStore.setState(() => ({ ...INITIAL_STATE, ... }));
// <-- blocksStore is never reset
// dispatcher.ts READY handler — the only repopulation path:
clearBlockedByThem(); // only the other direction is cleared
if (api !== undefined) {
api.listBlocks()
.then((r) => setBlockedByMe(r.blocked_user_ids))
.catch((err) => log.warn("Failed to load block list", { error: String(err) })); // stale set survives
}
Suggested fix: Add export function resetBlocksStore(): void { blocksStore.setState(() => ({ blockedByMe: new Set(), blockedByThem: new Set() })); } to blocks.store.ts and call it in clearAuth alongside resetChannelsStore() (auth.store.ts:93). Same-server reconnects don't go through clearAuth, so the keep-until-refetch behavior there is preserved.
Fixed: 8787b906 · test Client/tests/unit/auth-store.test.ts · revert-proof pass
OC-0055 — low — InviteManagerController.open() re-uses a pre-await root reference; a page teardown during the getInvites() fetch resurrects the overlay with a live document-level keydown listener that outlives the page
Client/src/pages/main-page/OverlayManagers.ts:166 · found 2026-08-12 · hunt general-2026-08-12 · lens client-state
open() captures const root = opts.getRoot() and checks instance !== null BEFORE await opts.api.getInvites() (line 167-171), but never re-checks getRoot()/liveness after the await. If MainPage.destroy() runs while the fetch is in flight, its unsubscribers call headerInviteCtrl.cleanup(), but instance is still null (createInviteManager hasn't run yet) so cleanup() no-ops; destroy() then nulls its own root variable and detaches the DOM node, but the closure-local root const in open() still references the now-detached node. When the fetch resolves after teardown, open()'s continuation runs unconditionally: it creates a new InviteManager instance and mounts it on the stale, detached root (the if (root !== null) check on line 201 only checks the stale local, never re-derives liveness). createInviteManager's mount() (Client/src/components/InviteManager.ts:218) registers document.addEventListener('keydown', ...) for Escape-to-close, scoped to that instance's own AbortController. Because InviteManagerController.cleanup() already fired and is never invoked again for this newly-created instance, that global keydown listener is never torn down — it lives on document indefinitely, closing over the destroyed page's api/getToast, and will fire options.onClose() the next time Escape is pressed anywhere in the app (e.g. after the user has navigated back to the connect/login page). SidebarArea.ts's own openQuickSwitch() (same file family, lines ~703-745) demonstrates the intended fix: it re-checks sidebarWrapper.parentElement === null AFTER the await before mounting, exactly the guard missing here (and in PinnedPanelController.toggle at OverlayManagers.ts:252-298, which has the identical pattern though its component has no document-level listener so the blast radius is smaller — a detached, un-destroyable component instance rather than a global listener leak).
Repro: 1) Open the sidebar, click the Invite button (SidebarArea.ts headerInviteBtn) while the network is slow, so opts.api.getInvites() is pending. 2) Before it resolves, log out / get banned / server-shutdown-kick (any path that calls MainPage.destroy()). destroy() runs headerInviteCtrl.cleanup() while instance is still null, so nothing happens; destroy() proceeds to null/remove root. 3) The pending getInvites() promise resolves; open()'s continuation creates a fresh InviteManager instance and mounts it onto the now-detached root, registering a document-level 'keydown' listener via createInviteManager's own AbortController. 4) The user is now on ConnectPage (or a new MainPage from re-login). Pressing Escape anywhere triggers the zombie instance's onClose→close(), which is the only thing that will ever call its destroy() — until then this dangling document listener is a real leak that nothing in MainPage's teardown chain can reach.
Suggested fix: In open(), after the await re-derive the mount target: const liveRoot = opts.getRoot(); if (liveRoot === null) return; and mount on liveRoot instead of the pre-await const (delete the dead if (root !== null) check). getRoot() returns MainPage's root, which destroy() nulls, so this is an exact liveness signal. Apply the same two-line change in PinnedPanelController.toggle.
Fixed: db0275a2 · test Client/tests/unit/overlay-managers.test.ts · revert-proof pass
OC-0056 — low — ws.disconnect() cannot cancel an in-flight connect(), so a cancelled session still opens its WebSocket and re-registers Tauri listeners after teardown
Client/src/lib/ws.ts:585 · found 2026-08-12 · hunt general-2026-08-12 · lens lifecycle
connect() is async and has three await points (ensureTauriApis, setupEventListeners' 3 tauriListen IPC round-trips, then ws_connect) after it has already bumped wsGeneration. disconnect() is fully synchronous and does NOT bump wsGeneration, so it has no way to invalidate an attempt that is mid-await: it drains eventUnsubs while that array is still partially filled, nulls config, and returns — then the suspended connect() resumes, pushes fresh (never-cleaned) unsub handles into eventUnsubs, and calls invoke("ws_connect"), opening the very socket the teardown was meant to prevent.
Repro: main.ts's ConnectPage onAutoLoginCancel (main.ts:572-588) is the exact interleaving, and its own comment says so: "by the time a click reaches here the session is already in flight (wirePostAuth has called ws.connect and registered listeners)". Click Cancel while auto-login is connecting → disconnect() runs during connect()'s awaits → connect() resumes and invokes ws_connect → Rust's WsState.begin_connection claims a fresh generation and completes the WSS handshake to the server the user just cancelled. The "open" event then flips the UI to authenticating (mapped to "reconnecting" by toConnectionStatus, so ServerBanner shows "Reconnecting..." on the connect page) and, because config === null, no auth frame is ever sent, so the socket sits unauthenticated until the server's 10s authDeadline closes it. The three tauriListen unsubs registered after disconnect()'s cleanupEventListeners() are never removed until the next connect(). The same shape applies to the logout path (main.ts:746, 814).
Evidence: connect(): wsGeneration++; config = cfg; intentionalClose = false; ... await ensureTauriApis(); ... cleanupEventListeners(); await setupEventListeners(); try { await tauriInvoke("ws_connect", { url: wsUrl }) }. disconnect(): intentionalClose = true; certMismatchBlock = false; cancelReconnect(); stopHeartbeat(); cleanupEventListeners(); void disconnectProxy(); setState("disconnected"); config = null; lastSeq = 0; reconnectAttempt = 0; — no wsGeneration++, no cancellation token consulted by connect(). The ws-state handler then hits setState("authenticating"); if (config === null) return;
Suggested fix: In disconnect(), add wsGeneration++;. In connect(), capture const gen = wsGeneration; after the initial increment and bail (if (gen !== wsGeneration) return;) after await ensureTauriApis() and after await setupEventListeners(), before invoking ws_connect.
Fixed: c3837fa · test Client/tests/unit/ws-lifecycle.test.ts · revert-proof self-reported
Client/src/lib/context-menu.ts:88 · found 2026-08-12 · hunt general-2026-08-12 · lens lifecycle
The teardown hook is attached with signal.addEventListener("abort", ...) with no { once: true } and no removal path — and, unlike the per-menu dismissAc, the caller's signal is the component's whole lifetime. Each invocation therefore adds one more listener to that signal, and each listener's closure retains its menu element, so menus removed from the DOM (by dismissal, by the querySelectorAll(...).remove() sweep at line 36, or by an item click) stay reachable until the component is destroyed.
Repro: Right-click DM rows N times without the DM sidebar being rebuilt: DmSidebar's ac.signal accumulates N abort listeners, each holding a detached .dm-context-menu div (plus its item children) that was already removed from the document. Nothing releases them until DmSidebar.destroy() fires the abort. Secondary consequence on the same line: if signal is already aborted when showContextMenu is called, the freshly-appended menu on document.body gets no teardown at all, because addEventListener("abort") on an already-aborted signal never fires.
Evidence: // Clean up if parent component is destroyed
signal.addEventListener("abort", () => {
menu.remove();
dismissAc.abort();
});
// caller (DmSidebar.ts:268) passes the sidebar-lifetime signal:
showContextMenu({ x: e.clientX, y: e.clientY, items, signal, className: "dm-context-menu" });
// where signal === ac.signal, aborted only in destroy() (DmSidebar.ts:347-348)
Suggested fix: Register the teardown hook so menu dismissal releases it: signal.addEventListener("abort", () => { menu.remove(); dismissAc.abort(); }, { signal: dismissAc.signal }), and abort dismissAc whenever the menu is removed (item click and outside-click already do).
Fixed: c3837fa · test Client/tests/unit/context-menu.test.ts · revert-proof self-reported
OC-0058 — low — Unban emits no WS event — *ws.Hub does not implement memberUnbanBroadcaster
Server/admin/handlers_users.go:169 · found 2026-08-12 · hunt general-2026-08-12 · lens state-desync
The ban path calls hub.BroadcastMemberBan(id) directly (a method *ws.Hub really has), but the unban path routes through a type assertion to memberUnbanBroadcaster, and BroadcastMemberUnban exists nowhere on *ws.Hub (grep finds it only in this file and admin/handlers_users_broadcast_test.go). The assertion always misses, so the DB (users.banned=0, the user is back in ListMembers) and every already-connected client's membersStore — which hard-deleted the row on member_ban — permanently disagree.
Repro: Admin bans user U: BroadcastMemberBan fans member_ban out, every connected client runs removeMember(U) and drops U from membersStore, and U's socket is kicked. Admin then unbans U via PATCH /api/v1/admin/users/{id} {"banned": false}. ModerationService.UnbanUser commits, then the case !*req.Banned && hub != nil branch type-asserts and silently does nothing. U is absent from the member list, from mention autocomplete and from getTypingUsers on every client that was connected during the ban, while any client that connects afterwards gets U in its ready payload — two clients side by side showing different rosters. It only converges for the stale clients if U reconnects (handleFreshConnect broadcasts member_join) or they reconnect themselves; an unbanned user who never comes back online stays missing indefinitely. admin/handlers_users_broadcast_test.go:51 asserts the call happens against a double that implements the interface, so the suite stays green.
Evidence: case *req.Banned && hub != nil:
hub.BroadcastMemberBan(id) // real method on *ws.Hub
case !*req.Banned && hub != nil:
if mub, ok := hub.(memberUnbanBroadcaster); ok {
mub.BroadcastMemberUnban(id) // *ws.Hub has no such method — assertion always false
}
Suggested fix: Implement func (h *Hub) BroadcastMemberUnban(userID int64) on *ws.Hub that loads the user and role from h.db and calls h.BroadcastToAll(buildMemberJoin(user, roleName)) — the client already maps member_join to addMember, so no protocol change is needed. Add a compile-time var _ memberUnbanBroadcaster = (*ws.Hub)(nil) where admin is wired to the real hub so the assertion cannot silently miss again.
Fixed: d2289560 · test TestBroadcastMemberUnban_FansOutMemberJoin · revert-proof pass
OC-0059 — low — Composer slow-mode cooldown is applied to whichever channel happens to be mounted when a chat_send_ok/SLOW_MODE frame arrives, not the channel the message was actually sent to
Client/src/pages/main-page/ChannelController.ts:450 · found 2026-08-12 · hunt general-2026-08-12 · lens state-desync
The chat_send_ok and error/SLOW_MODE listeners registered in mountChannel are global ws.on subscriptions (no per-channel filter is possible: ChatSendOkPayload has only message_id/timestamp, ErrorPayload has only code/message — neither carries channel_id). They are torn down and re-registered on every channel switch, so a frame that was actually produced by a send in the previous channel gets delivered to the newly mounted channel's handler, which unconditionally calls startSlowMode(ch.slowMode) for the channel currently mounted — desyncing the client's local slow-mode countdown (source: WS-listener side effect) from the server's actual per-channel rate-limit state (source of truth: the server's limiter, correctly scoped by channel_id there).
Repro: 1) Open channel A, which has slow_mode > 0. 2) Send a message in A (chat_send is sent, correlationId cid_A pending). 3) Immediately switch to channel B before the server's chat_send_ok (or a SLOW_MODE error, if A was already on cooldown) for cid_A arrives. destroyChannel() unsubscribes A's chat_send_ok/error listeners; mountChannel(B) installs B's. 4) The late chat_send_ok (or SLOW_MODE error) for the A-message arrives and is delivered only to B's handler, which reads channelsStore.get(channelId=B) and calls startSlowMode(B.slowMode) — disabling B's composer with 'Slow mode — Ns' even though B was never sent to and has no active server-side cooldown. This reproduces even with B.slowMode=0 replaced by any nonzero value; with B.slowMode=0 the call is a harmless no-op, but any channel with its own slow mode configured is falsely gated whenever the user switches into it right after posting in a slow-mode channel.
Suggested fix: Record the originating channel per correlation id (the send path already keys draftByCorrelation by correlation id — add a channelId field, or keep a controller-scoped Map<correlationId, channelId>). In both handlers, gate startSlowMode on that recorded channel equaling the mounted channelId; the server echoes the request id on SLOW_MODE errors too (buildErrorMsgWithID, Server/ws/handlers_chat.go:165), so the error handler can use the same correlation check via the second listener argument.
Fixed: 8787b906 · test Client/tests/unit/channel-controller.test.ts · revert-proof pass
OC-0060 — low — A single malformed stored server profile makes the client discard all saved profiles, and the next login overwrites the on-disk list with that empty set
Client/src/lib/profiles.ts:112 · found 2026-08-12 · hunt general-2026-08-12 · lens error-paths
createTauriBackend().load() returns null both for "nothing stored" and "stored payload failed validation", and isValidStoredData is all-or-nothing over the whole array — one bad entry rejects every profile. loadProfiles then does nothing (if (data !== null)), leaving the store empty rather than surfacing a read failure, and saveProfiles unconditionally writes that empty in-memory list back over the stored record. importProfiles on the same file shows the intended tolerance (it counts skipped per item); the load path has none.
Repro: A user has five saved server profiles. One stored entry fails isValidProfileShape — e.g. it was written by a build predating the color field, or a profile whose name is empty (obj.name.length > 0), or any hand-edit/partial write of the Tauri settings store. On launch, main.ts:625 calls loadProfiles(); load() returns null, so profiles stays [] and the connect page falls back to the synthetic "Local Server" entry (main.ts:439). Note main.ts:624-628's catch never fires — nothing throws. The user logs in; ensureProfileExists (main.ts:454) adds one profile and calls persistProfiles() (main.ts:449) → save_settings("owncord:profiles", {schemaVersion:1, profiles:[the one new profile]}). All five originals are now permanently gone from disk. Note tests/unit/profiles.test.ts:757 pins load() returning null for an invalid shape, but nothing pins the manager's behaviour after that null — the destructive overwrite is untested.
Evidence: async load(): Promise<StoredData | null> {
const raw = settings[STORAGE_KEY];
if (raw === undefined || raw === null) return null;
if (isValidStoredData(raw)) return raw;
return null; // validation failure == "nothing stored"
},
...
async loadProfiles(): Promise {
const data = await backend.load();
if (data !== null) { setProfiles(data.profiles); } // silently keeps []
},
async saveProfiles(): Promise {
await backend.save(toStoredData()); // writes [...currentProfiles()]
},
Suggested fix: Make load() salvage instead of discard: when the envelope shape is valid, return { schemaVersion, profiles: obj.profiles.filter(isValidProfileShape) } (mirroring importProfiles' per-item tolerance) so one corrupt entry drops only itself rather than nulling the whole store that the next save then overwrites.
Fixed: c3837fa · test Client/tests/unit/profiles.test.ts · revert-proof self-reported
OC-0061 — low — NewPersistentRateLimiter silently discards LoadActiveLockouts errors, dropping all active login lockouts with no log line
Server/auth/ratelimit.go:78 · found 2026-08-12 · hunt general-2026-08-12 · lens error-paths
The constructor only populates the in-memory lockout map inside if ... err == nil; there is no else branch, so a failed DB read (SQLITE_BUSY, disk I/O error, or any other transient error from the underlying SELECT in LoadActiveLockouts) is dropped with zero logging anywhere in the call chain. This is inconsistent with the same package's other persistence paths (Lockout/Reset also swallow their UpsertLockout/DeleteLockout errors silently) and starkly inconsistent with this codebase's own D8 'a drop is never silent' policy that the audit writer and event persister enforce for comparable best-effort persistence. The practical effect: every account currently serving a login/password/TOTP lockout (auth/ratelimit.go callers in api/auth_handler.go, api/profile_handler.go, api/totp_handler.go) has that lockout wiped from the in-memory limiter on any server restart where the load query errors — silently re-opening the account to brute force with no operator-visible signal that recovery failed.
Repro: 1) An operator has an account under an active login lockout (auth_handler.go's limiter.Lockout(...) after repeated failed logins), persisted via UpsertLockout into the lockout_log-style table. 2) The server restarts (deploy, crash-restart, container recycle) while the SQLite writer is briefly busy/locked or the disk hiccups, so d.q.LoadActiveLockouts returns an error. 3) router.go's auth.NewPersistentRateLimiter(database) call hits the err != nil branch of the if in NewPersistentRateLimiter, which has no body — the function returns a RateLimiter with an empty lockouts map for every shard, and nothing is logged. 4) The account that was mid-lockout is now immediately unlocked, and there is no log entry anywhere indicating the load failed, so the gap is invisible until someone notices the lockout 'reset itself'.
Suggested fix: Add an else branch: else { slog.Warn("ratelimit: failed to load persisted lockouts; starting with none", "err", err) } — one log line in the constructor makes the degradation operator-visible without changing the constructor's signature.
Fixed: 8787b906 · test Server/auth/ratelimit_persist_test.go · revert-proof pass
OC-0062 — low — Cold-tier reconnect replay has no interior-gap detection, so events the EventPersister dropped are silently skipped and presented as a complete resume
Server/ws/serve.go:210 · found 2026-08-12 · hunt general-2026-08-12 · lens flow-reconnect
EventPersister.Enqueue drops on a full queue and PersistEvents can lose individual rows on a per-row insert failure, so the events table can contain interior holes. handleReconnect's cold tier guards only two of the three gap shapes: a prefix gap (the GetEventsSince(ctx, 0, 1) oldest-seq probe, serve.go:198-209) and a tail gap (the ring-buffer coverage check, serve.go:223-233). Nothing checks for a hole in the middle, so the default: branch accepts a lossy result as authoritative, sends replay_source: "db", and the client — which tracks only max(seq) — advances past the missing seq and can never ask for it again.
Repro: A broadcast burst overflows the 4096-entry persister queue (or one PersistEvents row fails), so seq 1005 is never written to events while 1001..1004 and 1006..1200 are. A client disconnected at last_seq=1000 stays offline long enough for the 1000-entry ring buffer to evict seq 1000, forcing the cold tier. GetEventsSinceForChannels(1000, ...) returns 1001..1004,1006..1200; the oldest-seq probe returns a row with seq <= 1001 so serve.go:203 passes; the buffer covers the tail so serve.go:224 passes. The client receives auth_ok with replay_source:"db", applies 199 frames, and sets lastSeq=1200. If seq 1005 was a chat_deleted, channel_update or member_update, that state is permanently wrong on this client with no ready and no refetch to repair it.
Evidence: Server/ws/serve.go:210-214 default: persistedTail = make([][]byte, 0, len(persisted)); for _, p := range persisted { persistedTail = append(persistedTail, p.Payload) } — accepted with no contiguity/loss check.
Server/ws/event_persister.go:114-118 select { case p.queue <- ...: default: p.dropped.Add(1) } — silent drop on full queue.
Server/ws/event_persister.go:191-196 if failed := len(batch) - persisted; failed > 0 { p.errors.Add(...); slog.Warn("event persister: flush lost events", ...) } — rows lost on insert failure are counted and logged, never surfaced to the replay path.
Compare serve.go:188-197, whose own comment says "Accepting it as-is would present a hole as a complete resume, since the client tracks only max(seq)" — the exact hazard, guarded only for the prefix case.
Suggested fix: In serve.go's cold tier, before accepting persistedTail (the default branch at ~210), verify unfiltered contiguity of the covered range: query the store for COUNT(*) of events with lastSeq < seq <= maxPersistedSeq (unfiltered) and require it to equal maxPersistedSeq - lastSeq; on mismatch, log and force the full-ready fallback like the sibling guards. Contiguity is guaranteed absent losses because every allocated seq is persisted.
Fixed: 8787b906 · test Server/ws/reconnect_interior_gap_test.go · revert-proof pass
OC-0063 — low — Connected overlay reads authStore before the auth_ok payload is dispatched, so server_name and motd are always the pre-handshake values
Client/src/main.ts:388 · found 2026-08-12 · hunt general-2026-08-12 · lens flow-reconnect
ws.onStateChange listeners are invoked synchronously inside setState("connected"), which ws.ts runs before dispatch(msg) — and setAuth(token, payload.user, payload.server_name, payload.motd) only runs inside the dispatcher's auth_ok handler during that later dispatch. So authStore.getState() at main.ts:388 still holds the pre-auth_ok state. On a first login wirePostAuth has written only token, leaving serverName/motd at their INITIAL_STATE value of null, so both ?? fallbacks fire and the overlay renders the raw host string and a blank MOTD even though auth_ok carried both. Nothing later repairs it: the ws.on("ready", ...) handler at main.ts:403 only calls markReady(), and createConnectedOverlay captures its options by value at construction.
Repro: Configure a server with server_name = "My Guild" and a non-empty motd. Launch the client fresh and log in to 192.168.1.10:8443. auth_ok carries server_name:"My Guild" and the motd, but the connected overlay shows the title/avatar initial derived from "192.168.1.10:8443" (initial 1) and an empty MOTD line, because setAuth has not run when line 388 executes. ChannelSidebar/SidebarArea, which subscribe to authStore.serverName, show "My Guild" once MainPage mounts — proving the value did arrive and only the overlay read it too early.
Evidence: ws.ts:307-322 if (msg.type === "auth_ok") { ... setState("connected"); ... } dispatch(msg); and ws.ts:171-182 setState → for (const listener of stateListeners) listener(state) (synchronous).
dispatcher.ts:192-197 ws.on(S.AUTH_OK, (payload) => { ... setAuth(authStore.getState().token ?? "", payload.user, payload.server_name, payload.motd); ...}) — the only writer of serverName/motd.
main.ts:344 authStore.setState((prev) => ({ ...prev, token })); — the only pre-connect write; serverName/motd untouched.
stores/auth.store.ts:43-48 const INITIAL_STATE: AuthState = { token: null, user: null, serverName: null, motd: null, isAuthenticated: false };
main.ts:388-394 const auth = authStore.getState(); ... serverName: auth.serverName ?? host, ... motd: auth.motd ?? "",
Suggested fix: Create the overlay from the auth_ok payload instead of the store: in wirePostAuth, replace the onStateChange("connected") trigger with a one-shot ws.on("auth_ok", (payload) => { ... serverName: payload.server_name ?? host, motd: payload.motd ?? "" ... }) (registered after wireDispatcher), keeping the same self-unsubscribe and destroy-before-create logic.
Fixed: 8787b906 · test Client/tests/unit/main.test.ts · revert-proof pass
OC-0064 — low — The dispatcher's catch-all server-error branch writes to transientError, which only ConnectPage renders — every unhandled error is invisible in-app and then resurfaces stale on the login screen
Client/src/lib/dispatcher.ts:1002 · found 2026-08-12 · hunt general-2026-08-12 · lens flow-voice
setTransientError has exactly one consumer in the whole client: ConnectPage's subscription/mount read (ConnectPage.ts:267 and :276), which pushes it into the login form. MainPage never subscribes and never clears it. So the branch that the code comments call "the one place every remaining server error lands ... so it must not be silently dropped" does in fact drop it while the user is in the app, and leaves it latched in the store until the login page next mounts.
Repro: grep confirms only two read sites for transientError (ui.store.ts declaration aside): ConnectPage.ts:267,276. In a voice call, have a user with a LOWER user id join — the incumbent key holder's voice_e2ee_offer is answered with NOT_KEY_HOLDER (Server/ws/voice_e2ee.go:199), which matches none of the special-cased codes above (BANNED / pendingSends / reaction / CHANNEL_FULL / VIDEO_LIMIT) and falls into line 1002. Nothing is shown. Later the user hits Disconnect/logout; ConnectPage mounts, reads the latched value at line 276 and shows loginForm.showError("only the key holder may send key offers") on the login form — an error from a different screen, minutes earlier, presented as a login failure.
Evidence: dispatcher.ts:1002
setTransientError(payload.message || "Server error");
ConnectPage.ts:274-280
const pendingError = uiStore.getState().transientError;
if (pendingError) {
loginForm.showError(pendingError);
setTransientError(null);
}
(no other module reads uiStore.transientError)
Suggested fix: In the catch-all branch (dispatcher.ts:1002), surface in-app errors the same way the sibling CHANNEL_FULL/VIDEO_LIMIT branches do — showToast(payload.message || "Server error", "error") — keeping setTransientError only for flows that also leave the session (BANNED, shutdown), and update the dispatcher tests that assert the store write for the catch-all codes.
Fixed: 8787b906 · test Client/tests/unit/dispatcher.test.ts · revert-proof pass
OC-0065 — low — participant_joined webhook treats a GetVoiceState read error as proof of a rogue participant and ejects a legitimate one from the SFU
Server/ws/livekit_webhook.go:132 · found 2026-08-12 · hunt general-2026-08-12 · lens flow-voice
stateErr != nil is OR'd into the same condition as state == nil || state.ChannelID != channelID, so a transient DB read failure is indistinguishable from "no membership row" and results in RemoveParticipant. The sibling eviction path deliberately refuses to make that conflation: sweepStaleVoiceStates uses hasChannelPermChecked precisely because "a transient read failure (I/O error, lock contention, a maintenance window) is not a revocation" and skips the tick instead of evicting. The webhook has no such guard, and its removal is one-sided: it does not delete the voice_states row and does not broadcast voice_leave, so the server keeps believing the user is in voice.
Repro: User joins voice; the client connects to the SFU; LiveKit posts participant_joined. If h.db.GetVoiceState(ctx, userID) returns a transient error (SQLITE_BUSY under concurrent writes, an I/O error, a maintenance window), the handler logs "rogue participant_joined" and calls h.livekit.RemoveParticipant(...). The user is kicked out of the SFU mid-call while their voice_states row and hub voice state stay intact; other participants see no voice_leave, and their E2EE key holder does not rotate. The victim's client sees a non-CLIENT_INITIATED Disconnected and enters attemptAutoReconnect — which, per the 5-minute token TTL finding, also fails for any session older than 5 minutes.
Evidence: Server/ws/livekit_webhook.go:131-142
state, stateErr := h.db.GetVoiceState(ctx, userID)
if stateErr != nil || state == nil || state.ChannelID != channelID {
slog.Warn("livekit webhook: rogue participant_joined — no matching voice state, removing", ...)
if h.livekit != nil { h.livekit.RemoveParticipant(ctx, channelID, userID, joinToken) }
return
}
// contrast, Server/ws/hub_sweep.go:166-177
if err != nil {
// A transient read failure ... is not a revocation ... Skip this client this tick
continue
}
Suggested fix: Split the condition in handleWebhookParticipantJoined: on stateErr != nil, slog.Error and return WITHOUT calling RemoveParticipant (optionally retry the read once), so only a definitive nil row or channel mismatch is treated as rogue — mirroring sweepStaleVoiceStates' skip-on-error guard.
Fixed: 7be9ccd2 · test Server/ws/livekit_test.go + Server/ws/livekit_webhook_joined_test.go · revert-proof pass
OC-0066 — low — applyMentionCounts runs after SendMessage returns, so a mark_read that lands in between leaves a permanent mention badge on a channel with zero unread
Server/service/message_crud.go:218 · found 2026-08-12 · hunt general-2026-08-12 · lens flow-message
applyMentionCounts is dispatched to a background goroutine after the message is committed, and IncrementMentionCounts upserts mention_count = mention_count + 1 unconditionally — it never compares against the recipient's read state. UpdateReadState (the only writer that zeroes mention_count) can therefore run before the increment. The in-code rationale asserts the opposite outcome ("if a reader's channel_focus clears it in the tiny window before the increment lands, the badge simply does not reappear"); it does reappear, and because GetChannelUnreadCounts reports mention_count straight from the row while unread_count is computed from last_message_id, the next ready ships mention_count=1 with unread_count=0.
Repro: User U has channel C focused (read_states row: last_message_id=500, mention_count=0). User A posts message 501 containing @U; SendMessage commits row 501 and spawns the badge goroutine. Before that goroutine reaches IncrementMentionCounts, U switches channels — ChannelController.mountChannel fires markChannelRead(C) → mark_read → HandleChannelFocus → UpdateReadState(U, C, 501), setting last_message_id=501 and mention_count=0. The goroutine then runs and sets mention_count=1. The row is now (last=501, mention=1): GetChannelUnreadCounts reports unread_count=0, mention_count=1, so the next ready paints a red mention badge on C with nothing unread behind it, hasUnread(C) stays true and "Mark All as Read" stays lit until U opens C again.
Evidence: Server/service/message_crud.go:214-220 // The count is advisory: if a reader's channel_focus clears it in the tiny window before the increment lands, the badge simply does not reappear … s.bg(func() { s.applyMentionCounts(context.WithoutCancel(ctx), channelID, authorID, mentions, isDM, participantIDs) }) with bg: func(fn func()) { go fn() } (Server/service/message.go:144)
Server/db/mention_queries.go:214-218 INSERT INTO read_states (…) VALUES %s ON CONFLICT(user_id, channel_id) DO UPDATE SET mention_count = mention_count + 1
Server/db/dbgen/messages.sql.go:254-260 UpdateReadState … DO UPDATE SET last_message_id = excluded.last_message_id, mention_count = 0
Server/db/message_queries.go:588-594 ready's unread query: COUNT(*) … m.id > COALESCE(rs.last_message_id, 0) for unread, but COALESCE(rs.mention_count, 0) verbatim for mentions
Suggested fix: Thread the triggering message id into applyMentionCounts → IncrementMentionCounts and make the upsert read-state-aware: ON CONFLICT(user_id, channel_id) DO UPDATE SET mention_count = mention_count + 1 WHERE read_states.last_message_id < ?msgID. A reader whose read state already advanced past the mentioning message then gets a no-op instead of a phantom badge — one guard in the shared query, no caller changes beyond passing msgID.
Fixed: db0275a2 · test Server/db/mention_queries_test.go · revert-proof pass
OC-0067 — low — The identical GetDMParticipantIDs-failure gap silently drops chat_edited fan-out for DM edits
Server/service/message_crud.go:316 · found 2026-08-12 · hunt general-2026-08-12 · lens flow-message
EditMessage already wrote the new content via s.st.EditMessage before this block. When s.st.GetDMParticipantIDs then errors, the function logs and falls through (no early return) leaving result.ParticipantIDs at its nil zero value while still returning (result, nil). handleChatEditV2 (Server/ws/handlers_chat.go:122-128) builds MessageEditedDMEvent{participantIDs: result.ParticipantIDs} from that nil slice; EmitEvents -> sendSequencedToUsers iterates zero recipients (same code path as the SendMessage finding above), so the chat_edited frame reaches nobody, not even the editor's own other sessions. The other DM participant's client keeps showing the pre-edit content indefinitely (there is no other WS signal that would prompt a refetch of that message), even though the DB row and any REST re-fetch of channel history would already show the edited text -- a live desync between what is persisted and what every connected client displays.
Repro: A and B share a DM; A previously sent message M. A edits M while s.st.GetDMParticipantIDs(ctx, channelID) transiently fails inside EditMessage (Server/service/message_crud.go:316-321). The DB row for M is updated with the new content and edited_at, but result.ParticipantIDs stays nil, so MessageEditedDMEvent fans out to zero users. B's already-loaded message list keeps showing the original, pre-edit text with no edited marker, and nothing server-side ever pushes a correction to B's live session.
Suggested fix: Same shared guard as the send path: use context.WithoutCancel(ctx) for the GetDMParticipantIDs lookup (the edit is already committed, so the fan-out bookkeeping must not die with the editor's socket), and in handleChatEditV2 fall back to MessageEditedChannelEvent when result.IsDM && result.ParticipantIDs is empty so focused DM viewers still receive the edit live.
Fixed: db0275a2 · test Server/service/message_crud_test.go · revert-proof pass
OC-0068 — low — A DM message delete is committed but its chat_deleted fan-out is silently dropped when GetDMParticipantIDs fails
Server/service/message_crud.go:388 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-client-tauri-client-src
DeleteMessage logs a GetDMParticipantIDs error and returns a DeleteMessageResult with a nil ParticipantIDs, after the soft-delete has already committed. handleChatDeleteV2 builds MessageDeletedDMEvent from that nil slice and EmitEvents routes it to sendSequencedToUsers, whose recipient loop then iterates zero users — the delete succeeds server-side and reaches nobody, with no error returned to the deleter.
Repro: User A deletes their own message in a DM with B. The DeleteMessage row commits (message_crud.go:371) and the audit row is written, then GetDMParticipantIDs returns an error (SQLITE_BUSY under write contention, or a context deadline on a loaded server). ParticipantIDs stays nil, so MessageDeletedDMEvent carries no recipients and sendSequencedToUsers delivers to zero clients. The client only removes a row from messages.store on the CHAT_DELETED dispatcher event (dispatcher.ts:547-551) — ChannelController's onDeleteClick just sends chat_delete and toasts "Message deleted" — so A sees the success toast while the message stays on screen for A and for B until a full refetch. The frame did consume a seq and sits in the replay buffer, but every connected client's lastSeq watermark advances past it on the next frame, so a later reconnect can never request it back.
Evidence: service/message_crud.go:387-394
if isDM {
participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID)
if pErr != nil {
slog.Error("MessageService.DeleteMessage GetDMParticipantIDs", "err", pErr, ...)
} else {
result.ParticipantIDs = participantIDs
}
}
return result, nil
ws/hub_broadcast.go:607-619
func (h *Hub) sendSequencedToUsers(channelID int64, userIDs []int64, msg []byte) {
...
seq := h.nextSeq()
wrapped := wrapWithSeq(msg, seq)
h.replayBuf.Push(seq, channelID, wrapped)
h.persistEvent(seq, channelID, wrapped)
for _, userID := range userIDs { // empty -> delivered to nobody
h.SendToUser(userID, wrapped)
}
}
Suggested fix: In DeleteMessage, call s.st.GetDMParticipantIDs BEFORE s.st.DeleteMessage (participants do not change as a result of the delete) and return an error on failure, so no committed-but-unbroadcast state can exist. At minimum, wrap the existing post-commit fetch in context.WithoutCancel(ctx) to close the disconnect-after-commit window.
Fixed: db0275a2 · test Server/service/message_crud_test.go · revert-proof pass
OC-0069 — low — A DM reaction is persisted but its reaction_update fan-out is silently dropped when GetDMParticipantIDs fails
Server/service/message_reactions.go:147 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-client-tauri-client-src
Same shape as DeleteMessage: handleReaction commits the AddReaction/RemoveReaction row, then leaves result.ParticipantIDs nil on a GetDMParticipantIDs error. reactionV2Handler builds ReactionDMEvent from that nil slice, so the reaction_update reaches no participant while the DB row exists.
Repro: User A reacts to a message in a DM with B. s.st.AddReaction commits, then GetDMParticipantIDs errors (transient DB failure/context deadline). ParticipantIDs is nil, so sendSequencedToUsers delivers the reaction_update to nobody. B never sees the pill. On A's side the optimistic pill from addOptimisticReaction stays rendered but its pendingReactions entry (keyed by the WS envelope id) is never consumed by updateReaction, so it lingers until a disconnect rolls it back — at which point A's pill reverts even though the reaction is persisted server-side, and the two sides disagree until a refetch. No error is returned to A.
Evidence: service/message_reactions.go:146-155
if isDM {
participantIDs, pErr := s.st.GetDMParticipantIDs(ctx, msg.ChannelID)
if pErr != nil {
slog.Error("MessageService.handleReaction GetDMParticipantIDs", "err", pErr, ...)
} else {
result.ParticipantIDs = participantIDs
}
}
return result, nil
ws/handlers_reaction.go:46-52
if result.IsDM {
return Result{Events: []Event{ReactionDMEvent{
channelID: result.ChannelID,
participantIDs: result.ParticipantIDs, // nil
payload: reactionPayload,
}}}
}
Suggested fix: In handleReaction, fetch GetDMParticipantIDs before performing the AddReaction/RemoveReaction mutation and fail the request on error (participants are unaffected by the mutation), eliminating the committed-but-unbroadcast state. At minimum, use context.WithoutCancel(ctx) for the post-commit fetch.
Fixed: db0275a2 · test Server/service/message_reactions_test.go · revert-proof pass
OC-0070 — low — channel_focus has no archived-channel gate, so a client can subscribe to the live event stream of a channel every visibility surface hides — and reconnect replay then filters those same events out
Server/service/channel.go:245 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-ws
HandleChannelFocus gates only on READ_MESSAGES, and permissions.Checker.HasChannelPerm ignores ch.Archived (only VisibleChannelIDs applies the archived rule, checker.go:119). Every sibling path — buildReady, REST ListVisibleChannels, computeAllowedChannels, RefreshChannelVisibility and handleVoiceJoin — refuses or hides archived channels explicitly, so focus is the one path that lets a socket re-attach to one.
Repro: 1. Admin archives #foo via PATCH /admin/api/channels/{id}. handlePatchChannel calls RefreshChannelVisibility, which sends channel_delete to every client and calls pubsub.Unsubscribe(c, ChannelTopic(foo)) plus clears c.channelID — the channel is now hidden from ready, REST ListVisibleChannels and computeAllowedChannels.
2. A user who still holds READ_MESSAGES on #foo sends {"type":"channel_focus","payload":{"channel_id":}}.
3. HandleChannelFocus passes (the archived flag is never consulted), so handlers.go:170 re-subscribes the socket to ChannelTopic(foo) and UpdateReadState advances the user's read state on a channel that is not in their ready payload.
4. The socket now receives every chat_edited / reaction_update / chat_deleted / chat_bulk_deleted broadcast for #foo live (the archived read-only rule exists only on SendMessage), while computeAllowedChannels — used to filter reconnect replay — excludes #foo. On the next resume those identical events are dropped from replay, so the live stream and the resume path permanently disagree about the same channel, and the client's focused channel points at one its own channel list no longer contains.
Evidence: service/channel.go:240-247 (no ch.Archived branch)
if ch.Type == "dm" {
ok, err := s.st.IsDMParticipant(ctx, userID, channelID)
if err != nil || !ok { return nil, fmt.Errorf("%w: access denied", ErrForbidden) }
} else if !s.perms.HasChannelPerm(ctx, userID, channelID, permissions.ReadMessages) {
return nil, fmt.Errorf("%w: access denied", ErrForbidden)
}
permissions/checker.go:116-119 (archived is applied ONLY in VisibleChannelIDs)
// Archived channels are hidden from every client surface (admins ...)
if ch.Archived {
ws/voice_join.go:92-95 (the sibling gate that does exist)
if ch.Archived {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "channel is archived"))
return
}
ws/handlers.go:158-172 (a successful focus subscribes the socket to ChannelTopic)
if result.SetChannelID != nil { ... c.hub.pubsub.Subscribe(c, ChannelTopic(newChID)) }
Suggested fix: One guard in the shared service function (covers both channel_focus and mark_read): in HandleChannelFocus after the GetChannel lookup, add if ch.Type != "dm" && ch.Archived { return nil, fmt.Errorf("%w: access denied", ErrForbidden) }.
Fixed: 8787b906 · test Server/service/channel_test.go · revert-proof pass
Client/src/components/channel-sidebar/drag-reorder.ts:111 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
activeDrag captures the channelsContainer element that existed when the row was rendered. ChannelSidebar.renderChannels() does clearChildren(channelList) and rebuilds every category group, so any re-render while the mouse button is down leaves drag.containerEl (and drag.sourceEl, and the stale drag.channels snapshot) detached from the document. The global mouseup hit-test then queries the detached subtree, where getBoundingClientRect() returns an all-zero DOMRect for every row, so no row can ever satisfy e.clientY >= rect.top && e.clientY <= rect.bottom and dropTargetId stays null. The handler returns at the dropTargetId === null guard, and the drag is discarded with no error, no toast and no visual trace. The same staleness silently kills the drop indicator during mousemove (line 74-89 also queries the detached container), so the user watches the indicator disappear and then the drop does nothing.
Repro: Sign in with MANAGE_CHANNELS. Press the mouse down on a channel row and move >5px to start a drag. While still holding the button, have another user post a message in any channel that is not the active one (this calls incrementUnread, which builds a new channels Map, which fires the subscribeSelector at ChannelSidebar.ts:847, which runs renderChannels() and detaches the container captured in activeDrag). Release the mouse over a different channel row. Expected: the channel moves. Actual: drag.containerEl.querySelectorAll(...) returns rows whose getBoundingClientRect() is {top:0,bottom:0}, dropTargetId stays null, the handler returns, onReorder is never called and no position is written — the drag is lost with no feedback. Same happens on a category collapse, a connection-status flip, or any voice mute/camera change during the drag.
Evidence: drag-reorder.ts:100-125
const drag = activeDrag;
activeDrag = null;
...
const items = drag.containerEl.querySelectorAll("[data-drag-channel-id]");
let dropTargetId: number | null = null;
...
for (const item of items) {
const rect = item.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) { ... }
}
if (dropTargetId === null || dropTargetId === drag.channelId) {
return;
}
ChannelSidebar.ts:751-796 (renderChannels)
clearChildren(channelList);
...
for (const [category, channels] of grouped) {
channelList.appendChild(renderCategoryGroup(...)); // new channelsContainer every time
}
renderChannels() is wired to high-frequency stores:
ChannelSidebar.ts:847 channelsStore.subscribeSelector((s) => s.channels, () => renderChannels());
ChannelSidebar.ts:891 voiceStore.subscribeSelector(, () => renderChannels());
channels.store.ts:337-353 (incrementUnread) replaces the channel object AND the Map on every
message delivered to a non-active channel, so the selector above fires.
Suggested fix: In the global mousemove/mouseup handlers, when !drag.containerEl.isConnected, re-resolve the live container via document.querySelector([data-drag-channel-id="${drag.channelId}"])?.closest('.category-channels-container') (and rebuild the channel snapshot for that group from channelsStore) before hit-testing; or equivalently have renderChannels() re-target activeDrag's containerEl/sourceEl/channels when it rebuilds while a drag it owns is in flight.
Fixed: b1fb565 · test Client/tests/unit/drag-reorder.test.ts · revert-proof self-reported
OC-0072 — low — voice_mod_move's pre-flight omits the archived-channel gate that voice_join enforces, so the move drops the target out of voice for nothing
Server/ws/voice_moderation.go:295 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-ws
handleVoiceModMoveV2 documents itself as a pre-flight that "refuse[s] a move the re-join would only bounce, so the target is never dropped from voice for nothing", and it validates destination existence, type, the TARGET's CONNECT_VOICE, and capacity. It never checks dest.Archived, but the re-join it depends on (handleVoiceJoin, voice_join.go:92) refuses an archived channel with BAD_REQUEST. The handler has dest (a *db.Channel carrying Archived) in hand and simply does not consult it, so the move commits its destructive half — DB row deleted, LiveKit participant removed, voice_leave broadcast — for a re-join that is guaranteed to be rejected.
Repro: 1. Admin PATCHes voice channel B to archived=true (admin/handlers_channels.go:266 fires CleanupVoiceForChannel, so B is empty; B stays type="voice" with Archived=1). 2. Target user T is in voice channel A. 3. A moderator with MUTE_MEMBERS outranking T sends {"type":"voice_mod_move","payload":{"user_id":T,"to_channel_id":B}} (to_channel_id is client-supplied; no UI is needed). 4. Pre-flight passes: dest != nil, dest.Type == "voice", T holds CONNECT_VOICE on B (Archived is not part of permission resolution), capacity is free. 5. disconnectFromVoiceIn evicts T from A — voice_states row deleted, LiveKit participant removed, voice_leave broadcast — and voice_moved is sent. 6. T's client answers with voice_join B, which handleVoiceJoin rejects at voice_join.go:92 with BAD_REQUEST "channel is archived". T ends the sequence out of voice entirely, with an error and no way back to A except a manual rejoin — exactly the outcome the handler's doc comment says the pre-flight exists to prevent.
Evidence: dest, err := d.DB.GetChannel(ctx, c.ToChannelID())
...
if dest.Type != "voice" {
return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "destination is not a voice channel"}}
}
// ...no if dest.Archived branch anywhere in handleVoiceModMoveV2...
if !disconnectFromVoiceIn(ctx, d.Mod, c.TargetID(), state.ChannelID) { ... }
d.Mod.SendToUser(c.TargetID(), buildVoiceMoved(c.ToChannelID()))
// voice_join.go:92 — the gate the re-join actually applies:
if ch.Archived {
c.sendMsg(buildErrorMsg(ErrCodeBadRequest, "channel is archived"))
return
}
Suggested fix: In handleVoiceModMoveV2, immediately after the dest.Type check (voice_moderation.go:295-297), add: if dest.Archived { return Result{Error: ClientError{Code: ErrCodeBadRequest, Message: "channel is archived"}} } — same error shape voice_join.go:92 uses.
Fixed: db0275a2 · test Server/ws/voice_moderation_test.go · revert-proof pass
OC-0073 — low — channelReadAudience does not exclude archived channels, so admin edits to an archived channel are broadcast directly to every user whose base role has READ_MESSAGES
Server/ws/hub_broadcast.go:126 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-ws
channelReadAudience (used by broadcastChannelScoped -> BroadcastChannelUpdate/BroadcastChannelCreate, by broadcastVoiceEvent, and by CleanupVoiceForChannel's audience build) fetches the channel row at line 141 and only special-cases ch.Type=="dm"; it never checks ch.Archived. Its sibling RefreshChannelVisibility (same file, ~line 321: case ch.Archived: visible = false) explicitly treats an archived channel as invisible to everyone regardless of role, matching VisibleChannelIDs (permissions/checker.go:119, if ch.Archived { continue }) and the doc comment on RefreshChannelVisibility ('Archived channels are hidden from every client regardless of permissions'). channelReadAudience's own doc comment claims it 'Mirrors RefreshChannelVisibility, which resolves visibility the same way' but the archived check present there was never added here. The underlying HasChannelPerm/HasChannelPermBatch calls it delegates to (permissions/checker.go:69, service/permission.go:53-68) also never consult Archived — only the higher-level VisibleChannelIDs does. Delivery bypasses pub/sub entirely: deliverBroadcast's bm.recipients!=nil branch calls h.SendToUser per audience member directly (hub_broadcast.go ~line 667), so even a client that was never subscribed to the channel's topic (and never had it in its ready payload / sidebar) still receives the frame on its live socket.
Repro: 1) Admin archives voice channel #42 (handlePatchChannel, admin/handlers_channels.go, Archived: true committed to DB). Ordinary members' role has base READ_MESSAGES on #42 but the channel is now invisible everywhere else (VisibleChannelIDs excludes it from ready/reconnect, RefreshChannelVisibility sent them channel_delete/never showed it). 2) Admin PATCHes #42 again while it stays archived (e.g. edits topic/nsfw/slow_mode) — existing.Archived == updated.Archived so RefreshChannelVisibility is never called, but hub.BroadcastChannelUpdate(updated) always runs (admin/handlers_channels.go line 253) -> ws/hub_broadcast.go broadcastChannelScoped -> channelReadAudience(ctx, 42) returns every connected user whose role has READ_MESSAGES (archived not checked) -> deliverBroadcast SendToUser's the channel_update JSON (id, name, topic, category, archived flag) straight to those sockets, none of whom ever had #42 in their store or subscribed to its topic. Same gap fires if the admin archives a voice channel while people are still in it: CleanupVoiceForChannel (hub_sweep.go:332) calls channelReadAudience on the now-archived channel and broadcasts each evicted participant's voice_leave to the same over-broad, archived-blind audience, disclosing who was in the hidden voice channel to users who should never learn it exists.
Suggested fix: In channelReadAudience, inside the h.db != nil block after the GetChannel error handling (hub_broadcast.go:149), add: if ch != nil && ch.Archived { return []int64{} } — one guard in the shared audience function covers BroadcastChannelCreate/Update, broadcastVoiceEvent, finishVoiceLeave and CleanupVoiceForChannel at once (the archive-transition voice_leave fan-out keeps working because CleanupVoiceForChannel's audience is resolved before the eviction ordering matters only client-side, and its evicted-participant append is unconditional).
Fixed: 8787b906 · test Server/ws/hub_broadcast_test.go · revert-proof pass
OC-0074 — low — EditMessage's DM detection fails open on a GetChannel error, skipping the block gate and misrouting the edit fan-out
Server/service/message_crud.go:253 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-service
chanType stays "" when the channel read fails, so a DM edit takes the non-DM branch: requireDMNotBlocked and IsDMParticipant never run, and result.IsDM is false, so the ws layer emits MessageEditedChannelEvent (topic fan-out) instead of MessageEditedDMEvent (participant fan-out).
Repro: Bob has blocked Alice. Alice edits her own older DM message while GetChannel returns a transient error. isDM=false, so the requireDMNotBlocked branch at line 265 is skipped and checkSendPermission(ctx, userID, msg.ChannelID, "") runs the non-DM path, which passes on the base role mask (no override rows exist for a DM channel). The edit commits with arbitrary new text and, because result.IsDM is false, handleChatEditV2 (ws/handlers_chat.go:129) returns MessageEditedChannelEvent -> BroadcastToChannel(dmChannelID), delivering it to whoever holds the DM topic subscription rather than to the participant list — the exact channel back to the blocker that the requireDMNotBlocked doc comment says it exists to close.
Evidence: ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)
chanType := ""
if chErr == nil && ch != nil {
chanType = ch.Type
}
isDM := chanType == "dm"
Suggested fix: Fail closed: after line 253, if chErr != nil || ch == nil { return nil, fmt.Errorf("%w: cannot edit this message", ErrForbidden) } and derive chanType from ch.Type unconditionally.
Fixed: db0275a2 · test Server/service/message_crud_test.go · revert-proof pass
OC-0075 — low — handleReaction's DM detection fails open on a GetChannel error, letting a non-participant react inside a private DM
Server/service/message_reactions.go:105 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-service
Identical swallowed-error pattern: a failed channel read makes a DM take the role-based branch, bypassing IsDMParticipant and requireDMNotBlocked, and the reaction is then fanned out as ReactionChannelEvent instead of ReactionDMEvent.
Repro: Mallory (any role with READ_MESSAGES|ADD_REACTIONS, or any ADMINISTRATOR) sends reaction_add for a message id belonging to Alice and Bob's private DM while GetChannel errors. isDM=false -> HasChannelPerm resolves from the base mask on a channel with no override rows -> true -> the reaction row is written to a DM Mallory is not a participant of, and reactionV2Handler (ws/handlers_reaction.go:53) emits ReactionChannelEvent, publishing it onto the DM's channel topic where Alice and Bob see an outsider's reaction on their private message.
Evidence: ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)
isDM := chErr == nil && ch != nil && ch.Type == "dm"
if isDM {
ok, dmErr := s.st.IsDMParticipant(ctx, userID, msg.ChannelID)
...
} else if !s.perms.HasChannelPerm(ctx, userID, msg.ChannelID, permissions.ReadMessages|permissions.AddReactions) {
Suggested fix: Fail closed: after line 105, if chErr != nil || ch == nil { return nil, fmt.Errorf("%w: message not found", ErrBadRequest) } and compute isDM := ch.Type == "dm".
Fixed: 8787b906 · test Server/service/message_reactions_test.go · revert-proof pass
OC-0076 — low — The admin setup rate limiter is never reaped, so its window map grows without bound for the life of the process
Server/admin/api.go:34 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-service
setupLimiter is a dedicated auth.RateLimiter that nothing ever calls Cleanup or StartCleanup on — unlike the API limiter, which api/router.go:76 puts on a 5-minute reaper. Its windows map therefore accumulates one permanently-live entry per distinct source IP.
Repro: handleSetup calls limiter.Allow("setup:"+host, 5, time.Minute) at setup_handler.go:125, BEFORE the CreateOwnerIfEmpty check at :172 that rejects an already-configured server. So on a fully set-up, production server, every unauthenticated POST /admin/api/setup still allocates an entry in setupLimiter.shards[...].windows keyed by the peer IP and appends a time.Time — and nothing ever deletes it (RateLimiter.Cleanup is the only eviction path and is never invoked on this instance). A host reachable on an IPv6 /64 sees the map grow one entry (~key string + up to 5 time.Time) per source address indefinitely; the entries survive even though every request is 403ing.
Evidence: // admin/api.go:34
setupLimiter := auth.NewRateLimiter()
r.Post("/setup", handleSetup(database, setupLimiter, allowedOrigins, hub, setupOpts))
// vs api/router.go:76
go limiter.StartCleanup(rateLimiterCleanupInterval, rateLimiterCleanupMaxWindow, limiterStopCh)
Suggested fix: Mirror api/router.go: in NewAdminAPI start go setupLimiter.StartCleanup(5*time.Minute, 15*time.Minute, stopCh) (plumbing the router's existing limiterStopCh through, or reusing the router's already-reaped limiter for the setup endpoint).
Fixed: 8787b906 · test Server/admin/setup_limiter_reap_test.go · revert-proof pass
OC-0077 — low — DeleteMessage has no archived-channel gate — the read-only invariant has a fifth hole
Server/service/message_crud.go:329 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-service
SendMessage was fixed to refuse writes into an archived channel (message_crud.go:54-56, locked by TestSendMessage_RefusedInArchivedChannel), and the already-known finding at message_crud.go:268 documents that EditMessage, handleReaction, SetMessagePinned and PurgeMessages were left uncovered by that fix. DeleteMessage (lines 329-397) has the identical gap and was not named in that list: it fetches the channel at line 345 (ch, chErr := s.st.GetChannel(ctx, msg.ChannelID)) purely to determine isDM, and ch.Archived is never read anywhere in the function. Both the ownership-only DM path and the READ_MESSAGES|MANAGE_MESSAGES / owner-SEND_MESSAGES channel path proceed straight to s.st.DeleteMessage(ctx, msgID, userID, isMod) at line 371 regardless of the channel's archived flag, and the underlying db.DeleteMessage (db/message_queries.go:165) has no archived check either. This lets a member soft-delete their own message, or a moderator soft-delete anyone's message, in a channel the send path and every visibility surface treat as frozen — directly contradicting the SendMessage comment's stated invariant that 'History stays readable; only writes are refused.'
Repro: Archive channel 10 (UPDATE channels SET archived = 1 WHERE id = 10) after it has message history. A member who authored a message in channel 10 (or a moderator with READ_MESSAGES|MANAGE_MESSAGES on it) sends chat_delete for that message id. GetChannel returns Archived=true but DeleteMessage never inspects it; ownership/permission checks pass as normal; s.st.DeleteMessage soft-deletes the row and the handler broadcasts chat_deleted to the channel — the archive's history is silently mutated exactly the way SendMessage was fixed to prevent.
Suggested fix: In DeleteMessage, after the (fail-closed) GetChannel fetch: if !isDM && ch.Archived { return nil, fmt.Errorf("%w: channel is archived", ErrForbidden) } — matching SendMessage lines 54-56 (and the same one-line gate belongs in the sibling write sinks already tracked in the ledger).
Fixed: db0275a2 · test Server/service/message_crud_test.go · revert-proof pass
OC-0078 — low — renderAll's rapid-fire breaker discards the update instead of deferring it, leaving the message list permanently stale
Client/src/components/MessageList.ts:658 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
When more than 20 renderAll calls occur inside the 2s window the function returns before rebuildItems(), so the store change that triggered it is simply dropped. Nothing re-schedules a render when renderAllResetTimer clears the counter, so the DOM keeps showing pre-burst state until some later, unrelated store event happens to arrive.
Repro: In a busy channel, have 21+ non-append store updates land in separate microtask notifications inside 2s — e.g. a moderator purge that arrives as 25 individual chat_deleted frames, or a burst of reaction_update frames. tryAppendMessages() returns false for all of them (prefix comparison fails / next.length <= prev.length), so each one calls renderAll(). Calls 21-25 log "renderAll called >20 times in 2s" and return; allMessages/virtualItems still contain the deleted rows. Two seconds later the counter resets but no render is queued, so the deleted messages stay on screen — and stay clickable — until the next unrelated update (a new message, a roleRevision bump) triggers another renderAll.
Evidence: renderAllCount++;
if (renderAllCount > 20) {
log.error("[MessageList] renderAll called >20 times in 2s — breaking loop");
return; // <- update dropped, nothing re-queued
}
if (renderAllResetTimer === 0) {
renderAllResetTimer = window.setTimeout(() => { renderAllCount = 0; renderAllResetTimer = 0; }, 2000);
}
Suggested fix: When the breaker trips, remember it (e.g. renderAllSuppressed = true) and have the 2s reset timeout call renderAll() once if the flag is set, so the final state of a burst is always rendered.
Fixed: c3837fa · test Client/tests/unit/message-list.test.ts · revert-proof self-reported
OC-0079 — low — An emptied message edit is submitted (and edit mode torn down) when an attachment is queued in the composer
Client/src/components/MessageInput.ts:472 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-client-tauri-client-src
The empty-content early return is disabled by hasAttachments, but pendingAttachments is only meaningful for a new message — the edit branch never reads it. So with a file queued, an edit whose text the user cleared reaches onEditMessage(id, ""), which the host rejects with a toast, and cancelEdit() then runs unconditionally, dropping the user out of edit mode and wiping the textarea. The identical keystroke with no attachment queued is a harmless no-op that preserves edit state.
Repro: In a channel with uploads wired: (1) click "+" and attach any small image — the preview bar shows it and pendingAttachments.length === 1; (2) press ArrowUp on the empty composer (or click Edit on one of your messages) to enter edit mode — startEdit fills the textarea with the original content, pendingAttachments is untouched; (3) select all and delete the text, then press Enter. Expected (and what happens with no attachment queued): the send is refused at line 472 and the user stays in edit mode. Actual: hasAttachments is true, so the guard is skipped, onEditMessage(messageId, "") fires, a "Message cannot be empty" error toast appears, and cancelEdit() immediately exits edit mode and clears the textarea — the user has lost the edit and must re-open it.
Evidence: function handleSend(): void {
if (disabledReason !== null) return;
if (textarea === null) return;
const content = textarea.value.trim();
const hasAttachments = pendingAttachments.length > 0;
if (content.length === 0 && !hasAttachments) return; // <-- line 472
...
if (state.editing !== null) {
options.onEditMessage(state.editing.messageId, content); // content === ""
cancelEdit(); // runs regardless
}
// ChannelController.ts:357-362 (the onEditMessage host):
// const trimmed = content.trim();
// if (trimmed === "") { showToast("Message cannot be empty", "error"); return; }
// handlePasteFile only refuses attachments queued DURING an edit (MessageInput.ts:539):
// if (state.editing !== null) { showUploadError("Can't attach files while editing a message"); return; }
// It does not cover attach-then-edit, so pendingAttachments can be non-empty while state.editing !== null.
Suggested fix: In handleSend, make the empty-content guard ignore attachments when editing (edits are text-only): change line 472 to if (content.length === 0 && (state.editing !== null || !hasAttachments)) return;.
Fixed: b1fb565 · test Client/tests/unit/message-input.test.ts · revert-proof self-reported
OC-0080 — low — teardownForReconnect() has the same generation-guard gap as leaveVoice(), so a camera/screenshare enable racing an unexpected LiveKit disconnect can publish a track to the room being torn down for auto-reconnect
Client/src/lib/livekitSession.ts:348 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-client-tauri-client-src
roomEventHandlers.ts's handleDisconnected (line 184) calls deps.teardownForReconnect() on every unexpected disconnect that is eligible for auto-reconnect, before nulling the room and calling room.disconnect() (roomEventHandlers.ts:187-193). teardownForReconnect (livekitSession.ts:329-352) mirrors leaveVoice: it tells the server camera/screenshare are off, then calls stopManualCameraTrack(this._cameraState, this._room) and stopManualScreenTracks(this._screenState, this._room) directly (lines 348-349) and resets setLocalCamera(false)/setLocalScreenshare(false) (lines 350-351) — again without bumping state.generation, unlike doDisableCamera/doDisableScreenshare. An enableCamera()/enableScreenshare() call that is awaiting device acquisition when an unexpected disconnect fires will, on resuming, pass the stale-generation check and attempt to publish onto the room object that is about to be (or already was) disconnected and replaced by attemptAutoReconnect's fresh Room, leaving a leaked/orphaned local track and a store state that can disagree with what is actually being sent once the new room comes up.
Repro: 1) Join voice (room R1). 2) Click 'Enable camera'; enableCamera() captures room=R1, generation=0, and is awaiting createLocalVideoTrack() (device prompt already granted previously, so this await is just the getUserMedia latency, still enough for the race). 3) The LiveKit connection drops unexpectedly (network blip) — RoomEvent.Disconnected fires handleDisconnected, which calls teardownForReconnect(): stops manual tracks (no-op, nothing published yet), sends voice_camera(false)/voice_screenshare(false) if they were on, resets the store, but leaves this._cameraState.generation at 0; then the room is disconnected and replaced via attemptAutoReconnect. 4) createLocalVideoTrack resolves; enableCamera()'s stale-generation check still reads 0 === 0, so it sets state.manualCameraTrack and calls publishTrack on the old, disconnected R1 reference — a publish that races the reconnect instead of being cleanly superseded the way an explicit disableCamera() would have caused.
Suggested fix: Same one-line-per-state fix as the leaveVoice finding: call the shared supersede/bumpGeneration helper on this._cameraState and this._screenState at the top of the teardownForReconnect callback (before livekitSession.ts:348-349).
Fixed: 7be9ccd2 · test Client/tests/unit/livekit-session.test.ts · revert-proof pass
OC-0081 — low — voice_max_video cap counts the requester's own camera row, so a user whose server-side camera flag is already 1 can never re-enable
Server/db/queries/sqlite/voice.sql:92 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-ws
EnableCameraIfUnderLimit's guard subquery counts every camera=1 row in the channel, including the very row the UPDATE targets. An enable request from a user whose row already has camera=1 therefore needs maxVideo-1 other publishers to pass, so at the cap it is refused against the requester's own stream. The zero-rows result is also indistinguishable from "no voice_states row for this channel", and handleVoiceCameraV2 maps both to VIDEO_LIMIT "maximum N video streams reached".
Repro: Channel with voice_max_video = 1. User A enables their camera: COUNT(camera=1)=0 < 1, row updated to camera=1. A's client-side localCamera then falls out of sync with the row while the row stays 1 — the confirmed enableCamera supersession gap (Client/src/lib/screenShare.ts:243) does exactly this: a disableCamera that lands during publishTrack resets localCamera to false but the server row keeps camera=1. A now presses the camera button (VoiceCallbacks.ts:110 computes next = !localCamera = true) and the server runs EnableCameraIfUnderLimit(A, ch, 1): the subquery counts A's own row, 1 < 1 is false, 0 rows affected, ok=false. A receives VIDEO_LIMIT "maximum 1 video streams reached" while being the only video publisher in the room, and every retry repeats it — there is no path that clears camera back to 0 except A sending voice_camera{enabled:false}, which the UI will not do because it believes the camera is already off.
Evidence: Server/db/queries/sqlite/voice.sql:90-92 — UPDATE voice_states SET camera = 1 WHERE voice_states.user_id = ? AND voice_states.channel_id = ? AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = ? AND vs2.camera = 1) < ?; (no AND vs2.user_id <> ? exclusion, and no AND camera = 0 on the outer UPDATE). Consumed at Server/ws/voice_controls.go:116 ok, limitErr := d.DB.EnableCameraIfUnderLimit(ctx, userID, voiceChID, ch.VoiceMaxVideo) with the refusal at voice_controls.go:121-126 returning ErrCodeVideoLimit.
Suggested fix: Exclude the requester's own row from the count in EnableCameraIfUnderLimit: change the subquery to WHERE vs2.channel_id = ? AND vs2.camera = 1 AND vs2.user_id <> voice_states.user_id (or bind userID again with AND vs2.user_id <> ?), then regenerate the sqlc layer via the db-change workflow. This makes re-enable idempotent while still refusing a genuinely new publisher at the cap.
Fixed: 6a5a3a7c · test TestVoice_EnableCameraIfUnderLimit_ReEnableIdempotentAtCap · revert-proof pass
OC-0082 — low — Pinning a soft-deleted message returns HTTP 500: SetMessagePinned leaks db.ErrNotFound unwrapped, and lacks the deleted-message guard its siblings have
Server/service/message_query.go:227 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-service
SetMessagePinned is the only method in MessageService that returns a raw store error to its caller instead of wrapping it in the service error taxonomy. The pin SQL carries AND deleted = 0, so a soft-deleted target produces db.ErrNotFound, which errors.Is(err, service.ErrNotFound) does not match — writeServiceError falls through to default: and answers 500 INTERNAL_ERROR instead of 404. It is also the only message mutation with no msg.Deleted check: EditMessage returns ErrDeletedMessage (message_crud.go:248) and handleReaction returns ErrBadRequest (message_reactions.go:101).
Repro: Moderator A has the pinned-messages panel open showing message M (GET /channels/{id}/pins). Moderator B deletes M (soft delete: deleted = 1, pinned still 1). A clicks unpin → DELETE /api/v1/channels/{id}/pins/{M}. GetMessage still returns the row (db.GetMessage deliberately returns soft-deleted rows), so the channel/message checks pass; the UPDATE matches 0 rows, db.ErrNotFound propagates unwrapped, and the client gets 500 {"error":"INTERNAL_ERROR"} plus a server-side slog.ErrorContext("service error") line, rather than the 404 the sibling not-found paths return (locked by TestSetPinned_MessageNotFound / TestSetPinned_ChannelNotFound in api/channel_handler_test.go — neither covers the deleted case).
Evidence: service/message_query.go:223-227
(no msg.Deleted branch; raw store error returned)
db/queries/sqlite/messages.sql:27 UPDATE messages SET pinned = ? WHERE id = ? AND deleted = 0;
db/message_queries.go:732 return fmt.Errorf("SetMessagePinned: message %d: %w", id, ErrNotFound) — that is db.ErrNotFound (db/errors.go:11), a distinct sentinel from service.ErrNotFound (service/message.go:24).
api/channel_handler.go:407-426 (writeServiceError) has no db.ErrNotFound arm, so this lands in default: → 500.
Suggested fix: In service.SetMessagePinned, wrap the store call: if err := s.st.SetMessagePinned(ctx, msgID, pinned); err != nil { if errors.Is(err, db.ErrNotFound) { return fmt.Errorf("%w: message not found in this channel", ErrNotFound) }; return fmt.Errorf("%w: %v", ErrInternal, err) }. This maps the deleted case to 404, keeps genuine failures as 500, and — unlike only adding || msg.Deleted to the line-224 guard — also covers a delete racing between GetMessage and the UPDATE.
Fixed: db0275a2 · test Server/service/message_test.go · revert-proof pass
OC-0083 — low — ConnectPage.destroy() never clears uiStore.settingsOpen, so the settings panel pops open over MainPage immediately after login
Client/src/pages/ConnectPage.ts:286 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
MainPage.destroy() explicitly calls closeSettings() for exactly this reason ("the next page to mount an (initially hidden) SettingsOverlay off that flag — ConnectPage, after logout — would show it over the login screen"). ConnectPage.destroy() only destroys its own lazily-created overlay and leaves settingsOpen === true in the store. MainPage eagerly mounts a SettingsOverlay whose mount() ends with if (uiStore.getState().settingsOpen) show(), so the stale flag opens the full settings panel on top of the freshly loaded app.
Repro: On the connect page, type credentials and press Login; while the request is in flight (or during an auto-login), click the settings gear. openSettings() sets settingsOpen = true and the SettingsOverlay chunk starts loading. Login succeeds -> wirePostAuth -> WS ready -> ConnectedOverlay.markReady() fires onReady after READY_DELAY_MS (800 ms, ConnectedOverlay.ts:26/116) with no user interaction -> router.navigate("main") -> renderPage destroys ConnectPage (settingsOpen still true) -> MainPage mounts and its SettingsOverlay calls show(). The user lands in the app with the settings panel covering it, having never asked for it there.
Evidence: ConnectPage.ts:286-306 — destroy() { abortController.abort(); unsubSettingsOpen?.(); unsubTransientError?.(); settingsOverlay?.destroy?.(); settingsOverlay = null; setTransientError(null); ... } // no closeSettings()
MainPage.ts:756-762 — closeSettings(); // with the comment naming the symmetric ConnectPage case
MainPage.ts:416 + 501 — const settingsOverlay = createSettingsOverlay({...}); settingsOverlay.mount(root);
SettingsOverlay.ts:392-395 — // Sync initial state\n if (uiStore.getState().settingsOpen) { show(); }
ConnectPage.ts:78 — onSettingsOpen: () => openSettings() // the gear is never disabled during "loading"/"connecting" (LoginForm.updateFormInputsDisabled only touches host/username/password/invite)
Suggested fix: In ConnectPage.destroy() (ConnectPage.ts:286), call closeSettings() alongside the existing setTransientError(null), mirroring MainPage.destroy().
Fixed: db0275a2 · test Client/tests/unit/connect-page.test.ts · revert-proof pass
OC-0084 — low — VideoGrid's track-mute handler adds a track-muted class that no stylesheet defines, so a stalled remote camera keeps showing a frozen frame
Client/src/components/VideoGrid.ts:151 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
The handler's stated job is to hide the tile's video while the remote track is muted, but the hiding is expressed purely by toggling track-muted, and there is no .track-muted rule in app.css, base.css, login.css, tokens.css or theme-neon-glow.css. Nothing else in the mute path touches the element's visibility, so the branch is a no-op.
Repro: Join a video call with a remote peer, then have that peer's camera track fire mute (network stall, or the sender pausing the track). onTrackMute runs and adds track-muted to the .video-cell. Because no CSS matches that class, the <video> element keeps rendering its last decoded frame at full opacity — the viewer sees a live-looking but frozen tile with no indication the stream stalled, which is the exact state the handler was written to hide.
Evidence: VideoGrid.ts:148-156 —
const onTrackMute = (): void => {
// Temporarily hide video — track may unmute after network recovery
const cell = cells.get(userId);
if (cell !== undefined) cell.el.classList.add("track-muted");
};
const onTrackUnmute = (): void => { ... classList.remove("track-muted"); };
grep -rn "track-muted" src/styles/ -> no matches (compare .video-focus-main .video-cell at app.css:5091 and .video-focus-strip .video-cell at app.css:5104, which do exist).
Suggested fix: Add a rule to app.css alongside the other .video-cell styles, e.g. .video-cell.track-muted video { visibility: hidden; } (optionally with a background on .video-cell.track-muted), so the tile blanks while the track is muted and reappears on unmute.
Fixed: 8787b906 · test Client/tests/unit/video-grid-track-muted-css.test.ts · revert-proof pass
Client/src/pages/main-page/SidebarArea.ts:230 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-client-tauri-client-src
loadCollapsedCategories(serverHost) builds the localStorage key owncord:collapsed:<arg> and saveCollapsedCategories writes back under the same currentServerHost. The one caller passes authStore.getState().serverName — the operator-configured display name from auth_ok — not api.getConfig().host. Every sibling per-server preference in this client is host-scoped for exactly this reason (setChannelMutesHost, setNsfwGateHost, setAudioVolumeHost, all wired from MainPage.ts:105-107 with apiConfig.host), and channel-mutes.ts's own doc comment names ui.store.ts's loadCollapsedCategories as following that per-connection host convention. The server's default name is "OwnCord Server" (Server/config/config.go:212), so unmodified instances all collide on one key.
Repro: Save two profiles pointing at two different self-hosted servers, both left at the default server.name = "OwnCord Server", each with a category named e.g. "Text Channels". 1. Connect to server A, collapse "Text Channels" -> toggleCategory writes localStorage["owncord:collapsed:OwnCord Server"] = ["Text Channels"]. 2. Log out, connect to server B. SidebarArea calls loadCollapsedCategories("OwnCord Server") -> reads A's entry -> B's "Text Channels" renders collapsed although the user never collapsed it there. 3. Expand it on B -> the same key is rewritten to [] -> A's collapse state is destroyed. Renaming either server also orphans all of that host's saved state, because the key follows the name.
Evidence: // SidebarArea.ts:228-230
// Load per-server collapsed category state from localStorage
const initialServerName = authStore.getState().serverName ?? "Server";
loadCollapsedCategories(initialServerName);
// ui.store.ts:122-133
const COLLAPSED_KEY_PREFIX = "owncord:collapsed:";
let currentServerHost: string | null = null;
export function loadCollapsedCategories(serverHost: string): void {
currentServerHost = serverHost;
const raw = localStorage.getItem(COLLAPSED_KEY_PREFIX + serverHost);
// MainPage.ts:105-107 — the sibling prefs, all host-scoped
setChannelMutesHost(apiConfig.host ?? null);
setNsfwGateHost(apiConfig.host ?? null);
setAudioVolumeHost(apiConfig.host ?? null);
Suggested fix: Scope the key to the connected host like the sibling prefs: in MainPage.ts next to setChannelMutesHost/setNsfwGateHost/setAudioVolumeHost (lines 105-107), call loadCollapsedCategories(apiConfig.host ?? "") and delete the loadCollapsedCategories(initialServerName) call at SidebarArea.ts:228-230 (matching the per-connection wiring channel-mutes.ts:55-57 already describes).
Fixed: 8787b906 · test Client/tests/unit/sidebar-area.test.ts · revert-proof pass
OC-0086 — low — sweepStaleVoiceStates classifies ghost rows from a snapshot and then deletes them without re-checking, so an in-flight voice_join is ejected from the SFU while the hub still believes the user is in the room
Server/ws/hub_sweep.go:220 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-ws
The ghost loop decides staleness under one h.mu.RLock (lines 202-218) and then acts on that decision in a second, much longer loop that performs a DB delete, a channelReadAudience resolution, a broadcast and a LiveKit RemoveParticipant (5s timeout) per entry. The LeaveVoiceChannelIfMatch guard only protects against the user having moved to a different row — it matches the very row the in-flight join just created, because joined_at is identical. The revocation loop in the same function (lines 182-187) applies exactly the opposite discipline via handleVoiceLeaveIfStillIn, with a comment explaining why ("a voice_join to a still-permitted channel may have committed while it ran"); that reasoning was never carried into the ghost loop below it.
Repro: Voice channel V already holds several genuine ghost rows (e.g. after a server restart, or several crashed clients), so the stale loop has work to do and each iteration costs a channelReadAudience resolution plus a LiveKit RemoveParticipant with a 5s timeout.
- The 60s voiceSweepTicker fires. sweepStaleVoiceStates reads GetAllVoiceStates and takes the h.mu.RLock snapshot at T0.
- At T0, user X's voice_join has committed its row via JoinVoiceChannel (voice_join.go:196) but has not yet reached c.setVoiceState (voice_join.go:222) — one GetVoiceState round trip away. X's client still reports getVoiceChID() == 0, so X's row is appended to
stale.
- X's join then completes normally: setVoiceState, Subscribe(VoiceTopic(V)), updateKeyHolder(V), broadcastVoiceEvent(voice_state) — every other client is told X joined, and X's client connects to the SFU.
- Seconds later the sweep reaches X's entry. LeaveVoiceChannelIfMatch(X, V, joinedAt) matches — it is literally the row X's join created — and deletes it. voice_leave is broadcast for X. RemoveParticipant(V, X, joinedAt) uses the same identity and kicks X out of the LiveKit room.
- Nothing clears X's in-memory state: c.voiceChID is still V and c.voiceJoinToken is still joinedAt, and X remains subscribed to VoiceTopic(V).
Outcome: X is silently ejected from the SFU seconds after joining while the hub and X's own client both still believe X is in voice. updateKeyHolder(V) at line 245 scans h.clients, sees X's live voiceChID == V, and can elect X key holder for a room X is not in — every other participant's voice_e2ee_offer is then rejected with NOT_KEY_HOLDER. X's voice_mute/voice_camera writes hit zero rows and voiceStateBroadcast returns Result{} (state == nil), so the toggles silently no-op, and no later sweep can heal it because the sweep only iterates DB rows.
Evidence: hub_sweep.go:202-249
h.mu.RLock()
var stale []struct{ userID, channelID int64; joinedAt string }
for _, vs := range allStates {
c, ok := h.clients[vs.UserID]
if !ok || c.getVoiceChID() != vs.ChannelID { // <-- decided here, under one RLock
stale = append(stale, ...)
}
}
h.mu.RUnlock()
contrast, same function, hub_sweep.go:181-187
// The permission check is a DB round-trip; a voice_join to a
// still-permitted channel may have committed while it ran. The
// eviction is conditional on the client still being in the checked
// channel — never on whatever channel it is in by now.
if !h.handleVoiceLeaveIfStillIn(ctx, c, chID) { continue }
the window being raced, voice_join.go:196-222
if err := h.db.JoinVoiceChannel(ctx, c.userID, channelID); ... // row committed
state, err := h.db.GetVoiceState(ctx, c.userID) // still voiceChID == 0 here
...
c.setVoiceState(channelID, state.JoinedAt) // only now non-zero
Suggested fix: In the ghost-classification loop (hub_sweep.go:208-217), add the grace-period guard the codebase's own cross_batch note prescribes: parse vs.JoinedAt and continue for rows younger than a grace window (e.g. 2x the 60s sweep interval), so a just-committed join can never be classified as a ghost regardless of when c.setVoiceState lands.
Fixed: b8b7a2a1 · test TestSweepStaleVoiceStates_JoinCatchesUpDuringDeleteWindow · revert-proof covered-by-OC-0017
OC-0087 — low — Global search silently drops every DM hit when the DM-id lookup fails, and reports success
Server/service/message_perms.go:44 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-service
GetAccessibleChannelIDs discards the error from GetUserDMChannelIDs entirely — no return, no log, not even a slog.Warn — so a transient failure yields an accessible-channel set with every DM missing. SearchMessages then runs SearchMessagesInChannels over that truncated set and returns HTTP 200 with an authoritative-looking result list. The identical lookup in the WS sibling (ws/serve.go:505, computeAllowedChannels) is deliberately fatal, with a comment stating that a silently DM-stripped set is a permanent hole.
Repro: User U has one DM containing the message "deploy key rotated". GET /api/v1/search?q=deploy normally returns that hit via SearchMessages' global branch (message_query.go:100). Make GetUserDMChannelIDs fail once — SQLITE_BUSY against the single writer, or the request ctx being cancelled between the ListChannels/role reads and this call. GetAccessibleChannelIDs returns only the guild-channel ids, SearchMessagesInChannels is scoped to those, and the handler writes 200 {"results":[]}. Nothing is logged, and the user reads it as 'that message does not exist'. Worse: if U's role also has no visible guild channels, accessibleIDs is empty and SearchMessages returns (nil, nil) at message_query.go:104, i.e. a successful empty search rather than a 500.
Evidence: // Server/service/message_perms.go:43-49
// Also include DM channels the user participates in. Only the IDs are
// needed here, so skip the full DM query's preview/unread work.
dmIDs, err := s.st.GetUserDMChannelIDs(ctx, userID)
if err == nil {
ids = append(ids, dmIDs...)
}
return ids, nil
// Server/ws/serve.go:505-508 — same call, opposite posture
dmIDs, dmErr := database.GetUserDMChannelIDs(ctx, user.ID)
if dmErr != nil {
return nil, fmt.Errorf("computeAllowedChannels GetUserDMChannelIDs: %w", dmErr)
}
Suggested fix: Fail like the three sibling lookups in the same function: if err != nil { return nil, fmt.Errorf("%w: failed to fetch DM channels: %v", ErrInternal, err) }.
Fixed: 8787b906 · test Server/service/message_perms_test.go · revert-proof pass
OC-0088 — low — Registry.DispatchCommand reads runtimePlatform without the registry lock that Close() writes it under
Server/plugin/host_commands.go:71 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-service
DispatchCommand dereferences r.runtimePlatform (an interface-typed field, two words) with no lock, while Registry.Close sets it to nil under r.mu.Lock(). Registry.activate reads the same field under r.mu.RLock() and its doc comment states explicitly that the guard exists so a concurrent Close cannot be observed mid-use — DispatchCommand is the one reader that skips it, so the nil check can pass against a runtime Close has already torn down.
Repro: Build with -tags wazero and a plugin owning /roll. A WS client sends {"type":"chat_command","payload":{"command":"roll",...}} on a readPump goroutine at the moment shutdown runs Registry.Close on the main goroutine. DispatchCommand's unsynchronised read of r.runtimePlatform races Close's write: go test -race reports a data race on the field, and on a torn or stale-non-nil read invokeCommand proceeds into a wazero runtime whose modules Close has already deactivated (registry.go:120), panicking the readPump goroutine instead of returning the ErrRuntimeUnavailable diagnostic.
Evidence: // host_commands.go:71 — unguarded read
if r.runtimePlatform == nil {
return &CommandResult{...}, true
}
return r.invokeCommand(ctx, inst, userID, channelID, cmd, args)
// registry.go:132-135 — concurrent write under r.mu
r.platformClose = nil
r.runtimePlatform = nil
r.mu.Unlock()
// registry.go:447-452 — the sibling reader, guarded
r.mu.RLock()
platform := r.runtimePlatform
r.mu.RUnlock()
if platform == nil {
return ErrRuntimeUnavailable
}
Suggested fix: Capture the field under the RLock already held for the map lookup: r.mu.RLock(); inst, ok := r.commands[cmd]; platform := r.runtimePlatform; r.mu.RUnlock() and test platform == nil at line 71 (matching activate's pattern).
Fixed: db0275a2 · test Server/plugin/host_commands_race_test.go · revert-proof pass
OC-0089 — low — additionalBrowserArgs silently drops wry's default --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection on Windows
Client/src-tauri/tauri.conf.json:22 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
Tauri/wry pass --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection to the WebView2 browser process by default; setting additionalBrowserArgs REPLACES that default rather than appending to it (documented on WindowConfig::additional_browser_args / WebViewBuilder::with_additional_browser_args: "you also need to disable these components by yourself"). This config supplies only the two media flags, so the three suppressed WebView2 features are re-enabled in every Windows build — including SmartScreen, which performs URL-reputation lookups against a Microsoft service for navigations and downloads inside the webview. For a client whose entire threat model is a self-hosted, TOFU-pinned server, that leaks the operator's server and attachment URLs off-box, and the msWebOOUI overlays reappear in the chrome-less window.
Repro: Build the Windows (nsis) bundle and run it. Because the config's additionalBrowserArgs overrides wry's default argument string, the WebView2 process starts without --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection: SmartScreen is active for in-webview navigations/downloads (attachment and OG-preview URLs from the self-hosted server are submitted for reputation checks), and the msWebOOUI out-of-process UI surfaces render over the custom window. Fix is to prepend --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection to the same string. (Confidence note: verified against the documented Tauri/wry contract and the absence of the flag anywhere in the repo, not by running a Windows build.)
Evidence: Client/src-tauri/src-tauri/tauri.conf.json:22 (app.windows[0]):
"additionalBrowserArgs": "--autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream"
No occurrence of --disable-features anywhere in the repo:
grep -rn "disable-features" Client -> no hits
tauri = 2.11.5 (src-tauri/Cargo.lock:4809-4810), which carries the replace-not-append semantics.
Suggested fix: Prepend the dropped default to the same string in tauri.conf.json:22: "additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream".
Fixed: 8787b906 · test Client/tests/unit/tauri-conf-webview2-args.test.ts · revert-proof pass
OC-0090 — low — channelReadAudience falls through to a server-wide role scan when the channel row is gone, leaking a private group-DM's voice_leave to every connected member
Server/ws/hub_broadcast.go:149 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-ws
The DM-audience branch is guarded on ch != nil && ch.Type == "dm". A deleted channel yields ch == nil (db.GetChannel returns (nil, nil) on sql.ErrNoRows), so control drops into the generic role scan, which — for an id whose channel row and ON DELETE CASCADE'd channel_overrides no longer exist — resolves to "every connected user whose base role holds READ_MESSAGES". The read error above is deliberately failed closed; the missing-row case is not.
Repro: Group DM #G with alice, bob, carol. bob and carol leave. alice is alone in #G and is in its voice call (voice_states row + hub voiceChID set). alice calls DELETE /api/v1/dms/G. service.CloseDM -> db.LeaveGroupDM removes the last dm_participants row and, with remaining == 0, deletes the channels row, returning ChannelDeleted=true. api/dm_handler.go:236 then calls hub.DisconnectFromVoiceInChannel(alice, G) -> handleVoiceLeaveIfStillIn -> finishVoiceLeave (Server/ws/voice_leave.go:76) -> h.channelReadAudience(ctx, G). GetChannel(G) now returns (nil, nil), the ch.Type == "dm" branch is skipped, and the loop at hub_broadcast.go:171-178 calls h.perms.HasChannelPerm(uid, G, ReadMessages) for every connected user — with no override rows left for G, every ordinary member passes. Each of them receives {"type":"voice_leave","payload":{"channel_id":G,"user_id":alice}} for a private conversation they were never part of. The live-row form of exactly this leak is locked shut by Server/ws/voice_dm_access_test.go:179 (TestVoiceJoin_DMCall_VoiceStateNotLeakedToThirdConnectedUser); the nil-row branch bypasses it.
Evidence: ch, err := h.db.GetChannel(ctx, channelID)
if err != nil {
slog.Error("ws: channelReadAudience GetChannel failed, denying", ...)
return []int64{}
}
if ch != nil && ch.Type == "dm" { // <- nil (deleted) channel skips the DM audience entirely
participantIDs, err := h.db.GetDMParticipantIDs(ctx, channelID)
...
return audience
}
...
for _, uid := range userIDs {
if h.perms.HasChannelPerm(ctx, uid, channelID, permissions.ReadMessages) {
audience = append(audience, uid)
}
}
Suggested fix: In channelReadAudience (Server/ws/hub_broadcast.go), fail closed on a missing row the same way the lookup-error branch does: after the if err != nil block, add if ch == nil { return []int64{} } before the ch.Type == "dm" check. This is safe for every caller: finishVoiceLeave (voice_leave.go:88-98) and CleanupVoiceForChannel (hub_sweep.go:333-342) both union the room's remaining participants and the leaver into the audience after the call, so eviction/E2EE-teardown signals still reach everyone entitled to them, while non-participants get nothing.
Fixed: b22ad84d · test TestVoiceLeave_DeletedChannel_NotLeakedToNonParticipant · revert-proof pass
OC-0091 — low — chat_command is the only client message type registered without a rate limiter, and each frame runs a WASM plugin invocation
Server/ws/hub.go:155 · found 2026-08-12 · hunt general-2026-08-12 · lens hotspot-server-service
PluginDeps (deps.go:86) carries only Registry and MessageSvc — no *auth.RateLimiter — while every other deps struct built alongside it gets h.limiter (PingDeps line 130, ChatDeps line 132, PresenceDeps line 135, CallDeps line 138, VoiceDeps line 160). handleChatCommandV2 correspondingly contains no Allow(...) call, and there is no connection-level inbound message-rate cap either: serve.go:50 only sets SetReadLimit (a per-frame byte cap), and TopicRateLimiter meters outbound per-topic broadcast, not inbound frames. So one authenticated socket can drive unbounded Registry.invokeCommand executions.
Repro: An authenticated client loops {"type":"chat_command","payload":{"command":"/<any registered plugin command>","args":[]}} as fast as the socket allows. handleMessage → DispatchV2 → handleChatCommandV2 → reg.DispatchCommand → invokeCommand runs the wazero guest module once per frame with no throttle. Compare handleChatSendV2, which is capped at 10/s via auth.Key("chat", userID) (service/message_crud.go:32-35), and handleCallRingV2, capped at 1 per 3s (handlers_call.go:40-43). The command is not even required to be broadcast-eligible: the canPluginBroadcast gate is reached only after the plugin has already executed.
Evidence: ws/hub.go:155-158
reg.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{
Registry: func() *plugin.Registry { return h.pluginRegistry },
MessageSvc: h.messageSvc,
})
ws/deps.go:86-89
type PluginDeps struct {
Registry func() *plugin.Registry
MessageSvc *service.MessageService
} // no Limiter field
ws/hub.go:130-138 (every sibling gets one)
registerPingHandler(reg, PingDeps{Limiter: h.limiter})
chatDeps := ChatDeps{Limiter: h.limiter}
presenceDeps := PresenceDeps{Limiter: h.limiter}
callDeps := CallDeps{Limiter: h.limiter}
Suggested fix: Add Limiter *auth.RateLimiter to PluginDeps in Server/ws/deps.go, populate it with h.limiter in the RegisterV2 call at Server/ws/hub.go:155, and at the top of handleChatCommandV2 (Server/ws/handlers_command.go) add the standard idiom: if d.Limiter != nil && !d.Limiter.Allow(auth.Key("plugin_cmd", cc.userID), 5, time.Second) { return Result{Error: ClientError{Code: ErrCodeRateLimited, ...}} } before DispatchCommand.
Fixed: 8787b906 · test Server/ws/handlers_command_test.go · revert-proof pass
OC-0093 — low — Registration records the reverse-proxy's address as the session IP while login records the real client IP
Server/api/auth_handler.go:241 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
handleRegister is constructed without the trustedProxies list and uses clientIP(r), which is hardcoded to clientIPWithProxies(r, nil) — i.e. always r.RemoteAddr. The sibling handleLogin resolves the same value with clientIPWithProxies(r, proxyNets). Both feed the identical sessions.ip column that the client's "Active sessions" screen shows the user, so the two session-creation paths store two different things behind the same reverse proxy.
Repro: Deploy behind nginx at 10.0.0.2 with server.trusted_proxies: ["10.0.0.2/32"]. A client at 203.0.113.9 registers via POST /api/v1/auth/register: line 241 yields ip = "10.0.0.2" and line 257 stores that in the session row (and the audit/slog line). The same client then logs in via POST /api/v1/auth/login: line 307 yields ip = "203.0.113.9", stored by issueSession at line 451. The user's session-management list now shows one bogus 10.0.0.2 entry alongside correct ones, defeating the "is this session from somewhere I recognise?" purpose of the column. Note handleRegister(database) (line 137) does not even receive trustedProxies, although MountAuthRoutes has it (line 91) and passes it to every rate-limit middleware on the same route.
Evidence: auth_handler.go:137 func handleRegister(database *db.DB) http.HandlerFunc { // trustedProxies not threaded in
auth_handler.go:241 ip := clientIP(r)
auth_handler.go:257 database.CreateSession(r.Context(), uid, auth.HashToken(token), device, ip)
auth_handler.go:284 proxyNets := parseCIDRList(trustedProxies)
auth_handler.go:307 ip := clientIPWithProxies(r, proxyNets)
auth_handler.go:451 issueSession(r.Context(), database, user.ID, truncateDevice(...), ip)
middleware.go:251-253 func clientIP(r *http.Request) string { return clientIPWithProxies(r, nil) }
middleware.go:274-276 if len(trustedNets) == 0 { return remoteHost }
Suggested fix: Thread the proxy list into the register handler: change the signature to handleRegister(database *db.DB, trustedProxies []string), parse once at construction like handleLogin does (proxyNets := parseCIDRList(trustedProxies)), replace line 241 with ip := clientIPWithProxies(r, proxyNets), and update the mount at auth_handler.go:104 to pass trustedProxies.
Fixed: 8787b906 · test Server/api/auth_handler_test.go · revert-proof pass
Client/src/pages/main-page/SidebarArea.ts:523 · found 2026-08-12 · hunt general-2026-08-12 · lens fresh-eyes
channelBeforeDm (declared line 90, only ever written at line 393 inside selectDmConversation's setChannelBeforeDm callback) is meant to remember which channel to restore when leaving the DM sidebar. But SidebarDmSection.ts's "View all messages" button (line 66-68) switches to DM mode via a bare setSidebarMode("dms"), bypassing selectDmConversation entirely, so channelBeforeDm is never recorded for that entry path. When the user then clicks "Back" without selecting a specific DM, onBack (lines 523-536) sees channelBeforeDm === null and falls into the else branch, which activates the first type === "text" channel found in channelsStore Map-iteration order — not the channel that was actually on screen. If that first channel differs from the one the user was viewing, setActiveChannel (line 531) silently switches them to a different channel/conversation they never asked to leave.
Repro: User is viewing channel #random (not the first text channel in the sidebar's insertion order, e.g. #general is first). They click "View all messages" in the embedded DM section (SidebarDmSection.ts onclick -> setSidebarMode("dms") with no channelBeforeDm set). The DM sidebar opens, still showing #random underneath. They click "Back" without picking a DM. onBack sees channelBeforeDm === null and calls setActiveChannel(<id of #general>), so the app now displays #general instead of #random, with no user action having asked to leave #random.
Suggested fix: In onBack's else branch (SidebarArea.ts:528-535), keep the current channel when it is already a non-DM channel and only fall back when it is null or a DM: const st = channelsStore.getState(); const cur = st.activeChannelId !== null ? st.channels.get(st.activeChannelId) : undefined; if (cur === undefined || cur.type === "dm") { /* existing first-text-channel loop */ }. (fallBackFromDm at line 437 has the same pattern but is only reached after the active DM was closed, where the fallback is the intended behavior.)
Fixed: 8787b906 · test Client/tests/unit/sidebar-area.test.ts · revert-proof pass
OC-0095 — critical — Voice E2EE is never actually enabled — room.setE2EEEnabled(true) is never called, so every frame reaches the SFU in plaintext while the UI shows "🔒 Secured"
Client/src/lib/livekitSession.ts:410 · found 2026-08-13 · hunt 2026-08-13-postopt · lens voice-e2ee
createRoom() passes e2ee: { keyProvider, worker } to the livekit-client Room, but nothing in the client ever calls room.setE2EEEnabled(true). In livekit-client 2.21.0 the Room constructor's setupE2EE() only wires the manager; it never enables encryption. LocalParticipant.encryptionType therefore stays Encryption_Type.NONE, so on SignalConnected the E2EEManager calls setParticipantCryptorEnabled(localParticipant.isE2EEEnabled /* false */, localIdentity) and the worker's encodeFunction takes the if (!this.isEnabled()) { ... return controller.enqueue(encodedFrame); } branch — the frame is forwarded unencrypted. Published tracks are also advertised with encryption: NONE, so every remote peer's setParticipantCryptorEnabled(pub.trackInfo.encryption !== Encryption_Type.NONE, ...) is false too and their decode transforms pass through as well. The entire ECDH/HKDF/AES-GCM key exchange, the TOFU identity layer, key-holder election and 5-minute rotation distribute a room key that is never used to encrypt or decrypt a single media frame. The call still works end to end, which is why this has never surfaced as a symptom.
Repro: Join any voice channel with two clients. setupKeyExchange() completes, the key holder generates a room key, the peer unwraps it and keyProvider.setKey() succeeds, and both clients display "🔒 Secured". Because room.setE2EEEnabled(true) was never called, the local cryptor is disabled (encryptionEnabledMap has no true entry for the local identity), so the worker's encodeFunction enqueues each frame unmodified. Capture the RTP payload at the LiveKit SFU (or run livekit-server with track egress / a recording) and the audio/video decodes as ordinary Opus/VP8 — no SFrame header, no AES-GCM tag. Equivalent one-line check in the running client: room.isE2EEEnabled is false and room.localParticipant.encryptionType === Encryption_Type.NONE at every point of a "Secured" call.
Evidence: livekitSession.ts:408-413
// End-to-end encryption: SFrame-based E2EE using a server-distributed
// per-channel symmetric key. The SFU only sees encrypted frames.
e2ee: {
keyProvider: this._e2ee.keyProvider,
worker: this._e2eeWorker,
},
(no setE2EEEnabled / isE2EEEnabled anywhere under Client/src or tests/)
node_modules/livekit-client/dist/livekit-client.esm.mjs:29027
this.encryptionType = Encryption_Type.NONE; // LocalParticipant default
node_modules/livekit-client/dist/livekit-client.esm.mjs:29268
get isE2EEEnabled() { return this.encryptionType !== Encryption_Type.NONE; }
node_modules/livekit-client/dist/livekit-client.esm.mjs:29424 (LocalParticipant.setE2EEEnabled — the ONLY writer of encryptionType)
this.encryptionType = enabled ? Encryption_Type.GCM : Encryption_Type.NONE;
node_modules/livekit-client/dist/livekit-client.esm.mjs:15632 (E2EEManager.setupEventListeners, on SignalConnected)
this.setParticipantCryptorEnabled(this.room.localParticipant.isE2EEEnabled, this.room.localParticipant.identity);
node_modules/livekit-client/dist/livekit-client.esm.mjs:15602
room.on(RoomEvent.TrackPublished, (pub, participant) => this.setParticipantCryptorEnabled(pub.trackInfo.encryption !== Encryption_Type.NONE, participant.identity));
node_modules/livekit-client/dist/livekit-client.e2ee.worker.mjs:6741-6751 (Cryptor.encodeFunction)
if (!this.isEnabled()) {
this.appendFrameMetadata(encodedFrame);
return controller.enqueue(encodedFrame); // <-- plaintext passthrough
}
Client/src/components/VoiceWidget.ts:196
securedBadge.style.display = status === "connected" ? "inline-flex" : "none"; // "🔒 Secured" shown purely on connection state
Suggested fix: Enable E2EE on the one room construction site so both connect paths get it: make createRoom() async and await newRoom.setE2EEEnabled(true); immediately after new Room({...}) (livekitSession.ts:414), then await this.createRoom() at the two call sites (486, 950 — both already async). Pre-connect this only sets localParticipant.encryptionType = GCM (identity is still '' so the manager call is skipped); the manager's SignalConnected handler then posts enable=true with the real identity, and published tracks advertise encryption: GCM so peers enable their decode cryptors. The key is already set before connect (setupKeyExchange at 977 runs ahead of room.connect at 1009), so there is no encode-before-key window.
Fixed: 8579cb5d · test Client/tests/unit/livekit-session.test.ts · revert-proof pass
OC-0096 — high — Message search 500s on any query containing a hyphen — sanitizeFTSQuery allowlists the one character that is an FTS5 operator
Server/db/message_queries.go:36 · found 2026-08-13 · hunt 2026-08-13-postopt · lens db-storage
sanitizeFTSQuery is documented as stripping "FTS5 operator characters" but explicitly keeps '-'. In FTS5's MATCH grammar '-' is not a bareword character: it introduces a column-filter (-col : expr), so well-known parses as the term well followed by a filter on a column named known and SQLite raises no such column: known. That error propagates out of SearchMessages/SearchMessagesInChannels as service.ErrInternal, and the handler's isInvalidSearchQueryError (api/channel_handler.go:25-34) only matches "fts5"/"malformed"/"syntax error"/"unterminated string" — "no such column" matches none of them — so writeServiceError returns HTTP 500 instead of results.
Repro: Verified against the repo's exact FTS table (fts5(content, content='messages', content_rowid='id')) on modernc.org/sqlite v1.56.0:
MATCH 'well-known' -> ERROR: SQL logic error: no such column: known
MATCH 'e-mail' -> ERROR: SQL logic error: no such column: mail
MATCH '2026-08-13' -> ERROR: SQL logic error: no such column: 08
MATCH '-' -> ERROR: fts5: syntax error near ""
MATCH 'well known' -> ok, 1 row
So: GET /api/v1/messages/search?q=well-known (or any hyphenated term, e-mail address fragment, ISO date, "state-of-the-art", a username with a hyphen) returns 500 INTERNAL_ERROR. Only the pure-punctuation case (q="-") happens to produce an "fts5:"-prefixed message and gets the intended 400; every realistic hyphenated search is a 500. Global search (SearchMessagesInChannels, same MATCH) fails identically.
Evidence: db/message_queries.go:32-47
for _, r := range q {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' || r == '-' { // <- '-' kept
sb.WriteRune(r)
}
}
db/message_queries.go:348 / :360 / :410
WHERE messages_fts MATCH ? AND m.deleted = 0
db/message_queries.go:366
return nil, fmt.Errorf("SearchMessages: %w", err)
service/message_query.go:94 / :110
return nil, fmt.Errorf("%w: search failed: %v", ErrInternal, err)
api/channel_handler.go:29-33
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "fts5") || ... || strings.Contains(msg, "syntax error")
Suggested fix: One line in sanitizeFTSQuery: emit a space for '-' instead of keeping it, so the term still matches the indexed tokens (dropping it entirely would turn "well-known" into "wellknown", which matches nothing). At Server/db/message_queries.go:35-39: for _, r := range q { switch { case unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ': sb.WriteRune(r); case r == '-': sb.WriteRune(' ') } }. Both SearchMessages and SearchMessagesInChannels route through it, so one edit covers both. Also fix the vacuous guard: insert one message row in fuzzOpenMigratedMemory (Server/db/sanitize_fuzz_test.go:23-34) so the FTS cursor actually opens — without that, the seed corpus will keep passing on the next regression.
Fixed: 8579cb5d · test Server/db/message_queries_test.go · revert-proof pass
OC-0097 — high — Admin "Restore backup" writes to a hardcoded data/chatserver.db, so a restore silently no-ops on any server with a configured database.path
Server/admin/handlers_backup.go:177 · found 2026-08-13 · hunt 2026-08-13-postopt · lens error-paths
The restore handler hardcodes the destination as filepath.Join("data", "chatserver.db") while the live database is opened from cfg.Database.Path (main.go:129, default data/chatserver.db but a documented, editable config key). The pre-restore safety copy and the WAL checkpoint operate on the real DB, then copyFile(target, dbPath) writes the backup over an unrelated (possibly newly created) file. The handler then returns 200 "database restored — server restarting" and respawns. After the restart the server reopens cfg.Database.Path, which was never touched — an irreversible-looking operation is reported as success while doing nothing.
Repro: Set database.path: data/oc.db in config.yaml (or any non-default path) and start the server. Create a backup from the admin panel, make further changes (post messages, delete a channel), then click Restore on that backup. Response is 200 "database restored", the server restarts, and every post-backup change is still present — the backup was copied to data/chatserver.db, a file the server never opens. The operator, believing the rollback happened, may delete the backup.
Evidence: dbPath := filepath.Join("data", "chatserver.db")
...
if err := copyFile(target, dbPath); err != nil { ... }
...
writeJSON(w, http.StatusOK, map[string]string{"message": "database restored — server restarting", "backup": name})
Suggested fix: Give db.DB the path it was opened with (store path in openFile/openMemory, add func (d *DB) Path() string) and use database.Path() in handleRestoreBackup instead of the hardcoded join — one accessor fixes both the copy and the rollback, and the existing tests keep passing since the temp DB is opened at data/chatserver.db.
Fixed: 8579cb5d · test Server/admin/handlers_backup_test.go · revert-proof pass
OC-0098 — high — A joining key holder sends its room-key offers before its own announce, so every existing participant drops them as "unknown peer"
Client/src/lib/livekitE2EE.ts:230 · found 2026-08-13 · hunt 2026-08-13-postopt · lens flow-voice
setupKeyExchange drains the queued announces (which, for a key holder, emit a voice_e2ee_offer per peer) at lines 223-228, and only afterwards sends the holder's own voice_e2ee_announce at line 232. The receiver needs the sender's ephemeral ECDH public key to unwrap, so handleOfferInner's _peerPublicKeys.get(fromUserId) guard (line 737-741) discards every one of those offers. Nothing re-requests them: handleAnnounce answers an announce with an offer, never a counter-announce, and mid-call peers never re-announce on their own.
Repro: Server has users 1 (owner) and 50. User 50 joins voice channel #7 alone -> becomes key holder, roomKey R50. User 1 then joins the same channel. Server's computeIsKeyHolder (Server/ws/voice_join.go:311) returns true for uid 1 and voice_join relays user 50's stored announce to user 1 (voice_join.go:353), which queues in _pendingAnnounces because _ecdhKeyPair is still null. User 1's setupKeyExchange: _isKeyHolder=true, generates R1, keyProvider.setKey(R1), then the drain at line 224 calls handleAnnounce(50) -> wrap-and-offer branch (line 677-701) sends voice_e2ee_offer{target_user_id:50}; the announce for user 1 goes out only at line 232, i.e. AFTER that offer, on the same WS connection and therefore behind it in user 50's inbound FIFO (both relays land in the same c.send queue, see Server/ws/voice_e2ee.go:239 and :270). User 50 processes the offer first: _peerPublicKeys.get(1) is undefined -> logs "E2EE: received offer from unknown peer" and returns. User 50 then processes user 1's announce, stores the key, and — still _isKeyHolder — offers R50 back, which the server rejects with NOT_KEY_HOLDER (voice_e2ee.go:198, the map already names uid 1). Result: user 1 encrypts with R1, user 50 with R50, neither can decrypt the other, and the only repair is user 1's KEY_ROTATION_INTERVAL_MS timer 5 minutes later. Reproducible on every join where the joiner has the lowest user id in an ongoing call. The existing unit test (tests/unit/livekit-e2ee.test.ts:128) asserts only that an offer is sent during the drain, not its order relative to the announce, so moving the holder's announce above line 223 does not break it.
Evidence: this._ecdhKeyPair = ecdhKeyPair;
const queued = this._pendingAnnounces.splice(0);
for (const { userId: qId, publicKeyBase64: qKey, signatureBase64: qSig } of queued) {
await this.handleAnnounce(qId, qKey, qSig); // <- key holder sends voice_e2ee_offer here
log.info("E2EE: drained queued announce", { userId: qId });
}
// ...and the receiving side, line 737:
const peerKey = this._peerPublicKeys.get(fromUserId);
if (!peerKey) {
log.warn("E2EE: received offer from unknown peer", { fromUserId });
return;
}
Suggested fix: Hoist the key holder's announce above the drain: send {type:"voice_e2ee_announce", payload: announcePayload} immediately after this._ecdhKeyPair = ecdhKeyPair; (:217) when this._isKeyHolder, and delete the send at :232. Do NOT hoist the non-holder send at :244 — it must stay after the _roomKeyResolver is installed at :237-240, or an immediate offer resolves nothing and setupKeyExchange stalls to timeout.
Fixed: 8579cb5d · test Client/tests/unit/livekit-e2ee.test.ts · revert-proof pass
OC-0099 — high — Registration HTML-escapes the username but login does not, so any account whose name contains ' " & is permanently unloggable
Server/api/auth_handler.go:180 · found 2026-08-13 · hunt 2026-08-13-postopt · lens hotspot-server-api
handleRegister runs the username through the bare sanitizer.Sanitize (bluemonday StrictPolicy), whose output is always HTML-escaped, and stores the escaped form. handleLogin at line 295 does the opposite — it only trims — and GetUserByUsername matches the column exactly (username = ? COLLATE NOCASE). The two paths therefore canonicalize the same typed name differently. The codebase already solved exactly this for every other user-facing text field: sanitizeToFixpoint (service/message.go:167) exists because "a plain sanitizer.Sanitize call would persist and display literal '/>/& entities" (service/user.go:107-110), and display_name/about/custom_status all go through it. Username is the one field left on the raw call.
Repro: POST /api/v1/auth/register {username:"O'Brien", password:…, invite_code:…}. auth.ValidateUsername accepts it (only control/Cf runes are rejected; length 2-32). Line 180 stores O'Brien — verified against the vendored bluemonday: StrictPolicy().Sanitize("O'Brien") == "O'Brien", Sanitize("Bob & Alice") == "Bob & Alice", Sanitize(Say "hi") == "Say "hi"". Now POST /api/v1/auth/login {username:"O'Brien", password:…}: GetUserByUsername("O'Brien") returns (nil,nil), storedHash stays "", CheckPassword fails, 401 "invalid credentials" — forever. After 9 attempts the per-IP and per-username lockouts (auth_handler.go:389-395) lock the account for 15 minutes. Secondary symptom on the same line: escaping runs BEFORE auth.ValidateUsername (line 192), so a legal 30-rune name like "Sean O'Brien & Mary O'Sullivan" expands to 42 runes and is rejected with "username must be at most 32 characters". Third symptom: the stored name renders as the literal entity everywhere (client renders usernames via textContent) and cannot be @mentioned by typing the real name.
Evidence: api/auth_handler.go:180 req.Username = strings.TrimSpace(sanitizer.Sanitize(req.Username))
api/auth_handler.go:295 req.Username = strings.TrimSpace(req.Username) // login — no Sanitize
db/queries/sqlite/users.sql:5 FROM users WHERE username = ? COLLATE NOCASE;
service/message.go:167 return html.UnescapeString(sanitizer.Sanitize(html.UnescapeString(s))) // the fixpoint helper username never uses
Suggested fix: Stop HTML-escaping the username. Export the existing fixpoint helper (service/message.go:167 sanitizePass/sanitizeToFixpoint) as e.g. service.SanitizeText and replace the bare call at auth_handler.go:180 with req.Username = strings.TrimSpace(service.SanitizeText(req.Username)) — one shared helper used by both this call site and profile_handler.go:180. That keeps tag-stripping, drops the entity encoding, and keeps the length check operating on the real rune count.
Fixed: 8579cb5d · test Server/api/auth_handler_test.go · revert-proof pass
OC-0100 — high — Profile rename applies the same bare Sanitize, so renaming to a name with an apostrophe locks the user out of their own account
Server/api/profile_handler.go:180 · found 2026-08-13 · hunt 2026-08-13-postopt · lens hotspot-server-api
handleUpdateProfile escapes the new username with the same bare sanitizer.Sanitize and hands the escaped string straight to svc.Users.UpdateProfile, which writes it verbatim (service/user.go: s.st.UpdateUserProfile(ctx, userID, patch.Username, …) — username is the one ProfilePatch field that never goes through cleanText/sanitizeToFixpoint, unlike display_name and about right beside it). The login path never applies the same transform, so the rename silently changes the credential the account is reachable by. This is a separate call site from the register one: fixing either leaves the other broken.
Repro: Logged in as "alice", PATCH /api/v1/users/me {"username":"O'Brien"}. Line 180 turns it into O'Brien; UpdateProfile commits that to users.username and returns 200 with the escaped name. The session keeps working (it is token-based), so the damage is invisible until the token expires or the user signs out. On the next login with "O'Brien" the row is not found and the user is locked out permanently. Same line also inflates length before auth.ValidateUsername at line 187, so renaming to a 30-rune name containing three apostrophes 400s with "username must be at most 32 characters". Every subsequent profile save re-sends the stored escaped name (MainPage.ts:433 fills patch.username from authStore) — verified idempotent, so it does not compound, but the escaped name is now permanent.
Evidence: api/profile_handler.go:180 req.Username = strings.TrimSpace(sanitizer.Sanitize(req.Username))
api/profile_handler.go:187 if err := auth.ValidateUsername(req.Username); err != nil { // runs on the escaped string
service/user.go (UpdateProfile) s.st.UpdateUserProfile(ctx, userID, patch.Username, avatar, displayName, about) // username not cleanText'd
service/user.go:107-110 // "a plain sanitizer.Sanitize call would persist and display literal '/>/& entities"
Suggested fix: Same shared helper: replace the bare Sanitize at profile_handler.go:180 with the exported fixpoint sanitizer used at auth_handler.go:180, so both write-paths canonicalize identically to what login reads. (Existing rows already escaped need a one-off unescape migration, but the code fix is the single helper.)
Fixed: 8579cb5d · test Server/api/profile_handler_test.go · revert-proof pass
OC-0101 — medium — Channel-visibility watermark is bumped after the fan-out loop, so a client reconnecting during the loop replays past the change and never converges
Server/ws/hub_broadcast.go:365 · found 2026-08-13 · hunt 2026-08-13-postopt · lens ws-hub
RefreshChannelVisibility delivers the visibility change as targeted, unsequenced channel_create/channel_delete frames to a snapshot of clients taken at line 252, and only calls bumpVisibilityWatermark() at line 365 after the whole per-client loop finishes. A client that reconnects while the loop is still running is in neither audience: it was not in the snapshot, and handleReconnect's mustFullResync check (serve.go:125) reads the still-unbumped watermark and admits it to the replay path, which by construction cannot carry an unsequenced targeted message. revokeUnreadableChannels has the identical gap via defer h.bumpVisibilityWatermark() at hub_broadcast.go:497.
Repro: Server has N connected clients. Admin edits a channel_override on channel C (or edits a role, which drives RefreshAllChannelVisibility over every channel). RefreshChannelVisibility(C) snapshots h.clients at hub_broadcast.go:252-257, then loops lines 315-357 running 1-4 permission lookups plus a sendMsg + pubsub mutation per client. User U is offline at snapshot time (or drops immediately after) and reconnects mid-loop with last_seq = L, where L exceeds the previous watermark. handleReconnect evaluates mustFullResync(L) against the unbumped visibilityChangeSeq -> false -> U resumes from the ring buffer, never receives a ready payload, and never receives the targeted channel_create/channel_delete (it was not in the snapshot). Line 365 bumps the watermark only after U's handshake has already committed to replay. Revoked case: U's sidebar keeps rendering channel C forever, and clicking it is dead because registerNow's READ-gated re-subscribe correctly refused the topic. Granted case: U's computeAllowedChannels includes C so chat_message frames for C start arriving for a channel the client has no entry for. Neither converges until U reconnects with last_seq=0 or an unrelated visibility change pushes the watermark past L.
Evidence: hub_broadcast.go:252 h.mu.RLock(); clients := make([]*Client, 0, len(h.clients)) … loop 315-357 … 365 h.bumpVisibilityWatermark() // comment at 359-364: "Clients not connected right now missed the targeted sends above. Move the watermark so any resume from a seq at or before this point is forced onto the full-ready path" — but the bump happens after, not before, the sends. Same shape at hub_broadcast.go:497 defer h.bumpVisibilityWatermark().
Suggested fix: Bump before the audience is snapshotted instead of after it is served, in the one place each: in RefreshChannelVisibility move h.bumpVisibilityWatermark() from line 365 to immediately before the h.mu.RLock() snapshot at line 252; in revokeUnreadableChannels drop the defer on line 497 so the existing first-statement call runs eagerly (it already sits at the top, so early returns stay covered). That shrinks the hole from 'the whole fan-out loop' to the few instructions between the bump and the RLock. Residual, if you want it fully closed: re-check h.mustFullResync(lastSeq) inside the h.seqMu section right before h.registerNow in serve.go and fall back to full ready.
Fixed: ea0430c5 · test TestHandleReconnect_VisibilityChangeDuringHandshake_ForcesFullReady · revert-proof covered-by-OC-0206
OC-0102 — medium — Avatar upload rewrites the username from a pre-lock snapshot, silently reverting a concurrent rename
Server/api/profile_handler.go:575 · found 2026-08-13 · hunt 2026-08-13-postopt · lens api-authz
UserService.UpdateProfile serializes its read-merge-write under a per-user keyedMutex precisely because "PATCH /users/me can race POST /users/me/avatar ... silently reverting whatever the winner just changed". That lock only protects the fields merged from the row read inside it (avatar, display_name, about). Username is not merged — it is taken verbatim from the caller, and handleUploadAvatar supplies user.Username, a snapshot AuthMiddleware read at the start of the request, before the lock and before the whole multipart parse / image decode / disk write. So the exact lost update the lock was added to fix still happens, for the username column.
Repro: User "alice" is logged in on two clients. Client B starts POST /api/v1/users/me/avatar with a ~1 MiB PNG (AuthMiddleware snapshots user.Username = "alice"). While the upload is still parsing/decoding/writing, client A sends PATCH /api/v1/users/me {"username":"bob"} — it takes the lock, commits, and broadcasts user_update{username:"bob"}. Client B's handler then reaches UpdateProfile, takes the now-free lock, and writes patch.Username = "alice", reverting the rename. broadcastUserUpdate then pushes username "alice" to every connected client, so the rename is undone in the DB and on every peer with no error reported to either client. Same mechanism reverts db.DeleteAccount's "[deleted-]" anonymisation if a self-delete lands during an in-flight avatar upload.
Evidence: api/profile_handler.go:573-577
avatarURL := service.AvatarFileURL(fileID)
updated, err := svc.Users.UpdateProfile(r.Context(), user.ID, service.ProfilePatch{
Username: user.Username, // <- snapshot taken by AuthMiddleware, outside profileLocks
Avatar: &avatarURL,
})
service/user.go:156-171 (UpdateProfile)
unlock := s.profileLocks.lock(userID)
defer unlock()
current, err := s.st.GetUserByID(ctx, userID) // avatar/display_name/about merged from HERE
...
s.st.UpdateUserProfile(ctx, userID, patch.Username, avatar, displayName, about) // username NOT merged
Suggested fix: Make the username a merge like the other fields, in the shared function rather than at each caller: in UpdateProfile after current is read, username := patch.Username; if username == "" { username = current.Username }, pass username to UpdateUserProfile and to the audit/log lines, and drop Username: user.Username from the avatar handler's ProfilePatch. Empty is a safe sentinel because the only other caller (PATCH) already rejects an empty username at api/profile_handler.go:180-186.
Fixed: 8579cb5d · test Server/service/profile_fields_test.go · revert-proof pass
OC-0103 — medium — WAF inline engine rejects every request body >= 1 MiB, breaking plugin install and large avatar uploads when waf_enabled is on
Server/api/waf.go:213 · found 2026-08-13 · hunt 2026-08-13-postopt · lens api-authz
The inline Coraza engine sets SecRequestBodyLimit 1048576 but never sets SecRequestBodyLimitAction, and coraza's default is Reject (internal/corazawaf/waf.go:320 RequestBodyLimitAction: types.BodyLimitActionReject), so ReadRequestBodyFrom returns a 413 interruption as soon as the buffer reaches the limit. Its body-inspection exclusion (rule 900003) covers only /api/v1/uploads, while bodyCapExemptPrefixes exempts three routes from the app's own 1 MiB cap — /api/v1/uploads, /api/v1/admin/plugins/install (16 MiB) and /api/v1/users/me/avatar (2 MiB). The CRS engine built immediately below sets SecRequestBodyLimitAction ProcessPartial with the comment "never reject on size — request size enforcement belongs to the app middleware"; the inline engine contradicts its own sibling.
Repro: Set server.waf_enabled: true. As an Administrator on an allowed CIDR, POST a 2 MiB plugin .zip to /api/v1/admin/plugins/install (documented cap 16 MiB): the request never reaches PluginAdminHandler.install — the middleware answers 413 {"error":"request blocked by security rules"}. Same for POST /api/v1/users/me/avatar with an image at or just under maxAvatarFileBytes (1 MiB), whose multipart envelope pushes the body past 1,048,576 bytes. /api/v1/uploads is unaffected because rule 900003 turns requestBodyAccess off for it.
Evidence: api/waf.go:209-244 (inline engine — no SecRequestBodyLimitAction)
SecRequestBodyLimit 1048576
...
SecRule REQUEST_URI "@beginsWith /api/v1/uploads" "id:900003,phase:1,pass,nolog,ctl:requestBodyAccess=Off"
api/waf.go:111-112 (CRS engine, right next to it)
SecRequestBodyLimit 1048576
SecRequestBodyLimitAction ProcessPartial
api/constants.go:144-151
var bodyCapExemptPrefixes = []string{"/api/v1/uploads", "/api/v1/admin/plugins/install", "/api/v1/users/me/avatar"}
coraza v3 internal/corazawaf/transaction.go ReadRequestBodyFrom:
if tx.requestBodyBuffer.length == tx.RequestBodyLimit {
if tx.WAF.RequestBodyLimitAction == types.BodyLimitActionReject {
return setAndReturnBodyLimitInterruption(tx, 413)
api/waf.go:364-367
it, written, err := tx.ReadRequestBodyFrom(r.Body)
if it != nil { handleWAFInterruption(w, it); return }
Suggested fix: Extend the existing body-access exclusion instead of changing the limit action: make inline rule 900003 (and CRS rule 1001 for symmetry) cover the same prefixes as bodyCapExemptPrefixes, i.e. add SecRule REQUEST_URI "@beginsWith /api/v1/admin/plugins/install" "id:900004,phase:1,pass,nolog,ctl:requestBodyAccess=Off" and the same for /api/v1/users/me/avatar. Do NOT 'fix' this by copying SecRequestBodyLimitAction ProcessPartial onto the inline engine: with written > 0 the middleware replaces r.Body with the truncated 1 MiB buffer (api/waf.go:394-400), which would silently hand the handler a corrupt zip/image instead of a 413.
Fixed: 8579cb5d · test Server/api/waf_test.go · revert-proof pass
OC-0104 — medium — Hot plugin re-install leaves plugins.enabled = 1 while the runtime instance is deactivated, so the plugin silently stops working and the admin panel still shows it enabled
Server/plugin/registry.go:379 · found 2026-08-13 · hunt 2026-08-13-postopt · lens db-storage
InstallPlugin's upsert deliberately does not touch the enabled column, and installFromDisk tears down the old *Instance and registers a fresh one with Enabled:false without re-activating it. At startup that is fine because LoadAll follows installFromDisk with activateAll, which re-reads the store rows and re-activates every enabled one. InstallFromZip (the runtime upgrade path) calls installFromDisk and returns — nothing re-activates. The store row and the runtime therefore disagree: DB says enabled, the module is unloaded and its command bindings were dropped.
Repro: 1. Install plugin "foo" and enable it (POST /admin/plugins/{id}/enable): plugins.enabled = 1, module compiled, its slash commands registered in r.commands.
2. Upload a new foo.zip to POST /admin/plugins/install. InstallFromZip renames the new dir in and calls installFromDisk, which calls platformDeactivate(old), deletes old's command bindings, and inserts a new Instance with Enabled:false. The upsert leaves plugins.enabled = 1.
3. Result: foo's slash commands 404/no-op and it receives no events, but GET /admin/plugins still returns enabled:true, so the operator has no reason to click Enable. The plugin only comes back on the next server restart (LoadAll -> activateAll).
Evidence: plugin/registry.go:378-385 (InstallFromZip, Stage 4)
if err := r.installFromDisk(ctx, foundPlugin{...}); err != nil { ... }
return manifest.Name, nil // no activateAll / activate
plugin/registry.go:199-217 (installFromDisk)
if old := r.byName[found.Manifest.Name]; old != nil {
r.platformDeactivate(ctx, old)
for cmd, owner := range r.commands { if owner == old { delete(r.commands, cmd) } }
...
inst := &Instance{ ID: id, ..., Enabled: false }
db/plugin_queries.go:20-23
INSERT INTO plugins (name, version, enabled, manifest_json) VALUES (?, ?, 0, ?)
ON CONFLICT(name) DO UPDATE SET version = excluded.version, manifest_json = excluded.manifest_json
-- enabled is NOT reset, so the row stays 1
api/plugins_handler.go:118 (list)
rows, err := h.store.ListPlugins(ctx) // reports enabled=1
Suggested fix: Re-activate at the one place that skips it, reusing the existing lifecycle call instead of duplicating activation logic. In InstallFromZip after the installFromDisk block (Server/plugin/registry.go:385): if row, err := r.cfg.Store.GetPluginByName(ctx, manifest.Name); err == nil && row != nil && row.Enabled { if err := r.EnablePlugin(ctx, row.ID); err != nil { slog.Warn("plugin: reactivate after upgrade failed", "name", manifest.Name, "err", err) } }. EnablePlugin already sets inst.Enabled, activates, and rolls the DB flag back on failure, so the store row and the runtime can no longer disagree. Do not put this inside installFromDisk — LoadAll would then activate twice.
Fixed: db0275a2 · test Server/plugin/registry_test.go · revert-proof pass
OC-0105 — medium — secret_store::delete reports success while the fallback credential survives on disk and resurrects on next launch
Client/src-tauri/src/secret_store.rs:407 · found 2026-08-13 · hunt 2026-08-13-postopt · lens error-paths
clear_fallback removes the entry from the tauri-plugin-store's in-memory map and then flushes with store.save(). A save() failure is only log::warn!-ed, never propagated, so delete() (line 215–219) still returns the keyring result — Ok(()). The on-disk fallback file still holds the sealed secret, so the "deleted" login token/password or voice-E2EE identity key comes back the next time the app starts. This is exactly the failure delete's own doc comment claims to prevent ("a delete that left the fallback copy behind would resurrect a 'deleted' secret on the next read").
Repro: On a machine where the fallback engaged (Linux with no Secret Service, or after a keyring write failed its read-back), a credential is sealed into the fallback store file. Make that file (or its directory) read-only, or fill the disk. Call delete_credential ("Forget this server" / logout): keyring delete succeeds, store.save() fails, only a warn is logged, and the command resolves Ok so the UI reports the credential removed. Restart the client — get_fallback returns the still-present sealed blob and auto-login uses the supposedly deleted credential.
Evidence: fn clear_fallback(app: &AppHandle, account: &str) {
...
if store.delete(account) {
if let Err(e) = store.save() {
log::warn!("failed to flush credential fallback removal for '{account}': {e}");
}
}
}
pub fn delete(app: &AppHandle, account: &str) -> Result<(), String> {
let keyring_result = keyring_delete(account);
clear_fallback(app, account);
keyring_result
}
Suggested fix: Make clear_fallback return Result<(), String> (propagate the save() error) and have delete() return keyring_result.and(clear_fallback(app, account)); keep set_with's fallback_clear closure best-effort with let _ = ..., since there the keyring copy is authoritative.
Fixed: 8579cb5d · test Client/src-tauri/src/secret_store.rs · revert-proof pass (manual hunk-level: behavioral line reverted, propagation test red, green at HEAD)
OC-0106 — medium — Every DM message re-emits dm_channel_open and bumps the global visibility watermark, so ordinary DM traffic forces every other client's next reconnect onto the full-resync tier
Server/service/message_crud.go:205 · found 2026-08-13 · hunt 2026-08-13-postopt · lens flow-message
OpenDM is INSERT OR IGNORE ... :exec (dm.sql:2) and returns nil whether or not a row was actually inserted; dm_queries.go:427-435 discards the result too. SendMessage therefore appends every recipient to OpenedDMFor on every DM send, not just on a genuine (re)open. handlers_chat.go:75 then emits a DMChannelOpenEvent per recipient, and emit.go:41-47 calls h.bumpVisibilityWatermark() for each one, ratcheting visibilityChangeSeq up to the current global h.seq. mustFullResync(lastSeq) is w > 0 && lastSeq <= w (hub_events.go:86), so after any DM message every client whose lastSeq sits at or below that seq is denied both replay tiers on its next reconnect. This is distinct from the known hub_broadcast.go:365 ordering defect: here the watermark is a global gate being tripped by a strictly per-user event that only the addressee could ever have missed.
Repro: Alice and Bob have an already-open DM and exchange messages continuously. Each Alice→Bob send re-appends Bob to OpenedDMFor, emits a redundant dm_channel_open, and pushes visibilityChangeSeq to the current h.seq. Carol, unrelated, is idle in #general with lastSeq equal to the newest seq she has received. Her wifi blips; she reconnects with that last_seq. handleReconnect hits mustFullResync first (serve.go:125), logs "replay skipped (visibility changed since last_seq)", increments reconnectTierFull, and returns false — the ring buffer and the cold-tier EventStore are never consulted even though both fully cover her gap. handleFreshConnect sends a full ready; the dispatcher's second-ready branch (dispatcher.ts:301-327) then calls invalidateLoadedMessageWindows(), dropping every loaded channel's message window and refetching only the active channel. With steady DM traffic on the server the seq-resume design is effectively unreachable for every user who is not currently posting.
Evidence: Server/db/queries/sqlite/dm.sql:2 INSERT OR IGNORE INTO dm_open_state (user_id, channel_id) VALUES (?, ?);
Server/service/message_crud.go:201-206
if openErr := s.st.OpenDM(ctx, pid, p.ChannelID); openErr != nil { ...continue }
result.OpenedDMFor = append(result.OpenedDMFor, pid) // appended even when the row already existed
Server/ws/handlers_chat.go:75 if len(result.OpenedDMFor) > 0 { ... events = append(events, DMChannelOpenEvent{...}) }
Server/ws/emit.go:41-47 if _, isOpen := ev.(DMChannelOpenEvent); isOpen { h.bumpVisibilityWatermark() }
Server/ws/hub_events.go:84-87 func (h *Hub) mustFullResync(lastSeq uint64) bool { w := h.visibilityChangeSeq.Load(); return w > 0 && lastSeq <= w }
Suggested fix: Make the insert report whether it actually inserted, and gate the event on it. Change dm.sql OpenDM to :execrows, regen via the db-change skill, have db.OpenDM return (bool, error) (only production caller is message_crud.go:201; the two db tests just check err), and append to result.OpenedDMFor only when rows == 1. One guard in the shared path; handlers_chat.go and emit.go stay untouched.
Fixed: db0275a2 · test Server/service/message_crud_test.go · revert-proof pass
OC-0107 — medium — Session termination before MainPage mounts never tears down the WS client, so a banned/revoked token reconnects forever
Client/src/main.ts:729 · found 2026-08-13 · hunt 2026-08-13-postopt · lens flow-session
The only place that calls ws.disconnect(), deleteCredential(host) and router.navigate("connect") on logout is the authStore subscriber, and its entire body is gated on router.getCurrentPage() === "main". MainPage is not mounted until the connected overlay's onReady fires, so every session that dies during login / auto-login / the connected-overlay window skips the teardown: intentionalClose stays false in ws.ts, and rustState === "closed" therefore calls scheduleReconnect() (bounded delay 30s, no attempt cap) with the same dead token, forever. The server-side ban rejection makes this the default path: authenticateConn answers a banned user with buildErrorMsg(ErrCodeBanned, ...) — a generic error frame — not auth_error, and ws.ts only sets intentionalClose = true for auth_error.
Repro: Ban a user (admin panel), then start their client with auto-login enabled for that server (or have them log in and let the ban land while the 'Connected' overlay is still showing). Flow: wirePostAuth -> ws.connect -> server authenticateConn sees IsEffectivelyBanned -> writes {type:"error",payload:{code:"BANNED"}} -> closes with StatusPolicyViolation. Client: dispatcher's S.ERROR handler calls clearAuth(). In the auto-login case isAuthenticated was never true, so subscribeSelector (false->false) never fires at all; in the overlay case it fires but router.getCurrentPage() is still "connect", so the if body is skipped. Either way ws.disconnect() is never called, intentionalClose stays false, and the closed proxy event calls scheduleReconnect(). Result: the client re-sends the banned token every <=30s indefinitely, the UI stays stuck on 'Auto-connecting...', and the stale credential is never deleted (deleteCredential lives inside the same gated block). The only escape is the user clicking Cancel (main.ts:585) or killing the app.
Evidence: main.ts:726-746
authStore.subscribeSelector(
(s) => s.isAuthenticated,
(isAuthenticated) => {
if (!isAuthenticated && router.getCurrentPage() === "main") { // <-- gate
...
ws.disconnect();
...
if (host && authStore.getState().logoutReason !== "server_shutdown") {
void deleteCredential(host);
lib/ws.ts:296-303 (only auth_error sets intentionalClose)
if (msg.type === "auth_error") { intentionalClose = true; ... }
lib/ws.ts:439-441
if (!intentionalClose) { scheduleReconnect(); }
lib/ws.ts:209-210
function scheduleReconnect(): void { if (intentionalClose || certMismatchBlock || !config) return; // no attempt cap
Server/ws/serve_auth.go:85-87
if auth.IsEffectivelyBanned(user) {
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeBanned, "you are banned"))
lib/dispatcher.ts:930-934 (BANNED handler calls clearAuth() only)
if (payload.code === "BANNED") { setTransientError(...); clearAuth(); return; }
lib/store.ts subscribeSelector fires only when the slice changes, so false->false never notifies.
Suggested fix: In dispatcher.ts's S.ERROR BANNED branch (line ~930), call ws.disconnect() before clearAuth() — ws is already in wireDispatcher's scope and disconnect() sets intentionalClose/cancels the reconnect timer, so it is idempotent with the main-page subscriber's own ws.disconnect(). One guard in the shared handler covers every router state instead of adding a third hand-copied teardown block.
Fixed: 8579cb5d · test Client/tests/unit/dispatcher.test.ts · revert-proof pass
OC-0108 — medium — A username/nickname/avatar change never repaints the open message list — updateMemberProfile is the one members.store mutator that does not bump roleRevision, and MessageList subscribes to nothing else
Client/src/stores/members.store.ts:152 · found 2026-08-13 · hunt 2026-08-13-postopt · lens explore-2
updateMemberProfile returns { ...prev, members: next } while every sibling mutator (setMembers:78, addMember:102, removeMember:111, updateMemberRole:122) returns roleRevision: (prev.roleRevision ?? 0) + 1. MessageList holds exactly one members subscription — membersStore.subscribeSelector((s) => s.roleRevision ?? 0, () => renderAll()) at MessageList.ts:897-903 — so the profile patch mutates the store without ever waking the only renderer that reads it. That renderer does read it live: renderers.ts:200 calls resolveAuthor(msg.user), and formatting.ts:143 resolves the name/avatar out of membersStore.getState().members, with a doc comment (formatting.ts:131-135) stating the store is preferred precisely because "a rename or an avatar change arrives as a user_update and patches every member". The store is patched; the pixels are not.
Repro: 1. User A and user B are both in #general; B has posted several messages that are on A's screen. 2. B renames themselves (or sets a nickname, or changes their avatar) — the server emits user_update. 3. dispatcher.ts:700 calls updateMemberProfile, which patches membersStore.members but leaves roleRevision unchanged. 4. MessageList's only members subscription selects roleRevision, so shallowEqual(prev, next) on the unchanged counter is true and renderAll() never fires. A's message list keeps showing B's OLD name and OLD avatar. 5. It does not self-heal on the next message either: messagesStore.subscribeSelector at MessageList.ts:859 runs if (!tryAppendMessages()) renderAll(); — the append path succeeds for a new tail message, so only the new row is built and the already-rendered rows keep the stale identity. The stale rows persist until something forces a full renderAll(): a channel switch, a history page load, or an unrelated member join/leave/role change. Meanwhile MemberList (which subscribes to the whole store, MemberList.ts:463-467) shows the new name — so the sidebar and the message list disagree about who B is. No test locks the current behavior: roleRevision appears nowhere under tests/.
Evidence: members.store.ts:137-154 (updateMemberProfile)
return { ...prev, members: next }; // <-- no roleRevision bump
vs. members.store.ts:122 (updateMemberRole)
return { ...prev, members: next, roleRevision: (prev.roleRevision ?? 0) + 1 };
MessageList.ts:896-903 — the ONLY membersStore subscription in the component:
membersStore.subscribeSelector(
(s) => s.roleRevision ?? 0,
() => { renderAll(); },
)
formatting.ts:143 — read live per rendered row:
const member = membersStore.getState().members.get(user.id);
if (member !== undefined) return { username: member.username, displayName: member.displayName ?? null, avatar: member.avatar };
dispatcher.ts:698-705 — the event that lands here:
ws.on(S.USER_UPDATE, (payload) => { updateMemberProfile(payload.user_id, { username, avatar, displayName, identityPublicKey }); })
Suggested fix: One line in the shared mutator: make updateMemberProfile return { ...prev, members: next, roleRevision: (prev.roleRevision ?? 0) + 1 } (members.store.ts:152), and update the MembersState doc comment at line 30-34 (and MessageList.ts:894) to say the counter covers membership, role, and profile changes — still excluding presence/typing, which is the exclusion the counter exists for.
Fixed: 7be9ccd2 · test Client/tests/unit/members.store.test.ts · revert-proof pass
OC-0109 — medium — GIF proxy's log redaction misses the percent-encoded API key, so an upstream failure writes the Klipy credential to stdout and the admin log stream
Server/api/gif_handler.go:176 · found 2026-08-13 · hunt 2026-08-13-postopt · lens hotspot-server-plugin
The upstream URL is built with params.Encode(), which percent-encodes every character outside [A-Za-z0-9-_.~]. url.Error embeds that encoded URL in err.Error(). redactKey does a literal strings.ReplaceAll(s, apiKey, "[REDACTED]") against the decoded key, so it never matches the encoded form and the credential survives into the log line. The sibling in the same package got this right: livekit_proxy.go redacts the raw query blob first "so an encoded form is caught too", then the decoded token.
Repro: Set gif.api_key to any key containing a character Encode() escapes (base64-style keys routinely contain '+', '/' or '='), e.g. "ab+cd/ef=". Make api.klipy.com unreachable (block DNS or the outbound dial). Call GET /api/v1/gif/trending. gifClient.Do fails with a *url.Error whose message contains ...?key=ab%2Bcd%2Fef%3D&limit=20...; redactKey compares against the literal "ab+cd/ef=" and matches nothing, so the full encoded key lands in stdout and in admin.RingBuffer, from which any admin can read it live over the SSE /admin/api/logs/stream endpoint.
Evidence: gif_handler.go:150 results, err := fetchGIFs(r, gifAPIBase+upstreamPath+"?"+params.Encode(), apiKey, limit)
gif_handler.go:117 "key": {apiKey},
gif_handler.go:176 slog.Warn("gif proxy: upstream request failed", "error", redactKey(err.Error(), apiKey))
gif_handler.go:215 return strings.ReplaceAll(s, apiKey, "[REDACTED]")
livekit_proxy.go:191-192 safeErr := redactKey(err.Error(), backendURL.RawQuery) // raw query FIRST
safeErr = redactKey(safeErr, backendURL.Query().Get("access_token"))
Suggested fix: Fix it once in the shared helper rather than at each call site — gif_handler.go:211: func redactKey(s, apiKey string) string { if apiKey == "" { return s }; s = strings.ReplaceAll(s, apiKey, "[REDACTED]"); return strings.ReplaceAll(s, url.QueryEscape(apiKey), "[REDACTED]") }. net/url is already imported in this file, the second pass is a no-op when the key needs no escaping, and both the three GIF call sites and the livekit proxy inherit the fix.
Fixed: 8579cb5d · test Server/api/gif_handler_internal_test.go · revert-proof pass
OC-0110 — medium — Every error attribute is blanked to {} in the admin live log stream — err never reaches the log viewer
Server/admin/logstream.go:298 · found 2026-08-13 · hunt 2026-08-13-postopt · lens explore-1
ringHandler.Handle stores a.Value.Any() into a map[string]any and then json.Marshals it. Go error values (*errors.errorString, *fmt.wrapError) have only unexported fields, so they marshal to {}. slog's own JSONHandler special-cases error and emits err.Error(); this handler does not, so the stdout sink and the admin ring buffer disagree on the single most important field of every error line.
Repro: Trigger any slog.Error(msg, "err", err) — e.g. logstream.go:95 slog.Error("failed to issue log stream ticket", "err", err), or the hundreds of "err", err / "error", err call sites across ws/, db/, service/. stdout shows err="generating ticket: ...". The admin panel's Logs section (and the SSE /admin/api/logs/stream feed) shows ERROR [admin] failed to issue log stream ticket {"err":{}}. Searching the log viewer for any substring of the error message never matches, because attrs contains only {}.
Evidence: logstream.go:293-307
r.Attrs(func(a slog.Attr) bool {
key := a.Key
...
attrs[key] = a.Value.Any()
return true
})
var attrsJSON string
if len(attrs) > 0 {
if b, err := json.Marshal(attrs); err == nil {
attrsJSON = string(b)
}
}
Verified: json.Marshal(map[string]any{"err": fmt.Errorf("wrap: %w", errors.New("boom"))}) -> {"err":{}} (marshal error nil, so the empty object is silently kept).
Consumer: Server/admin/static/index.html:1540 renders entry.attrs inline, and :1530 log search matches against entry.attrs.
Suggested fix: One shared conversion in ringHandler.Handle, applied to both the rh.attrs loop (line 290) and the r.Attrs callback (line 298), instead of a.Value.Any(): func logAttrValue(v slog.Value) any { v = v.Resolve(); if e, ok := v.Any().(error); ok { if _, jm := e.(json.Marshaler); !jm { return e.Error() } }; return v.Any() }. The Resolve() half matters too: Record.Attrs does not resolve slog.LogValuer, so the ring path currently also bypasses the redaction in Server/db/logvalue.go and Server/config/logvalue.go, storing the raw User/Session/Config value instead of the redacted one. This mirrors what slog's own JSONHandler does for error values.
Fixed: db0275a2 · test Server/admin/multihandler_test.go · revert-proof pass
OC-0111 — medium — Auto-idle's return-to-online presence_update is always swallowed by the 1-per-10s presence limiter, so every user shows as Idle to everyone else after returning to the keyboard
Client/src/pages/MainPage.ts:200 · found 2026-08-13 · hunt 2026-08-13-postopt · lens explore-1
applyPresence() writes the local presence store unconditionally but only sends the wire frame if limiters.presence.tryConsume() succeeds, and the presence limiter is 1 token per 10 000 ms (rate-limiter.ts:154). autoIdle fires apply(true)->onStatusChange("idle") and then, on the very first mouse event (unthrottled by design, autoIdle.ts:119-124), apply(false)->onStatusChange("online") milliseconds later. The second call always loses the token, so the local store/localStorage say "online" while the server and every other client keep the user at "idle", with no retry and no user feedback.
Repro: 1. Sit idle 10 min. autoIdle.apply(true) -> MainPage.applyPresence("idle") -> updatePresence local + ws.send consumes the single presence token at t=0. 2. Move the mouse at t=+50 ms. autoIdle.onActivity sees idleByTimer===true, calls apply(false) -> MainPage.applyPresence("online"). 3. updatePresence(userId,"online") runs; limiters.presence.tryConsume() returns false (sliding window still holds the t=0 stamp), so ws.send({type:"presence_update",payload:{status:"online"}}) never runs. 4. Own client renders Online; every other member list and the server DB still hold "idle". Nothing retries — restoreSavedPresence only runs on a connectionStatus transition to "connected".
Evidence: MainPage.ts:195-203
function applyPresence(status: UserStatus): void {
const userId = getCurrentUserId();
if (userId !== 0) { updatePresence(userId, status); }
if (limiters.presence.tryConsume()) {
ws.send({ type: "presence_update", payload: { status } });
}
}
rate-limiter.ts:153-155 export function createPresenceLimiter(): RateLimiter { return createRateLimiter(1, 10_000); }
autoIdle.ts:119-124 if (idleByTimer) { apply(false); ... }
Suggested fix: In applyPresence (the shared function all three call sites route through), stop dropping the frame silently — on a failed tryConsume, schedule one retry: keep a module-level let presenceRetry: ReturnType<typeof setTimeout> | null, and in the else branch clearTimeout(presenceRetry) then presenceRetry = setTimeout(() => applyPresence(loadUserStatus()), limiters.presence.getRemainingMs()); clear it in the page teardown next to autoIdle.destroy(). Coalescing on the latest status means a burst still costs one frame, and restoreSavedPresence should call applyPresence rather than duplicating the same unguarded shape.
Fixed: 7be9ccd2 · test Client/tests/unit/main-page.test.ts · revert-proof pass
OC-0112 — medium — handleServeFile's admin bypass covers the DM branch, so an ADMINISTRATOR can download attachments from private DMs they are not a participant in
Server/api/upload_handler.go:302 · found 2026-08-13 · hunt 2026-08-13-postopt · lens explore-1
if !isAdmin { ... } wraps the entire access-control block, including the aa.ChannelType == "dm" participant check. Every sibling DM read gate in the codebase deliberately has NO admin bypass — service.requireChannelRead (message_query.go:25-30) returns ErrNotFound for a DM the caller is not in even for an Administrator, and PermissionService.RequireChannelAccess (permission.go:73-82) and MessageService.checkSendPermission (message_perms.go:70-80) do the same. The file route is the one path where the admin bit opens a private DM.
Repro: Alice and Bob exchange a 1:1 DM with an attachment; the attachment row gets channel_id = the DM channel, channel_type = "dm". An operator with the ADMINISTRATOR bit (not a DM participant) calls GET /api/v1/files/{id}: isAdmin is true at line 300, the whole if !isAdmin block at 302-359 is skipped, IsDMParticipant is never consulted, and the file is served 200. The same operator calling GET /api/v1/channels/{dmChannelID}/messages gets 404 "access denied" from requireChannelRead. The file id is discoverable without DB access: handleUpload logs it at line 229 (slog.Info("file uploaded", "id", fileID, "filename", safeFilename, ...)), and that line is streamed verbatim to the admin panel's live log viewer. TestServeFile_AdminBypassesAllChecks (upload_handler_test.go:1214) only exercises an unlinked attachment, so the DM case is not test-locked.
Evidence: upload_handler.go:300-302,335-350
isAdmin := role != nil && permissions.HasAdmin(role.Permissions)
if !isAdmin {
...
if aa.ChannelType == "dm" {
ok, dmErr := database.IsDMParticipant(r.Context(), user.ID, *aa.ChannelID)
if dmErr != nil || !ok { ...403... }
vs Server/service/message_query.go:25-30 (no admin arm)
if ch.Type == "dm" {
ok, dmErr := s.st.IsDMParticipant(ctx, userID, channelID)
if dmErr != nil || !ok { return fmt.Errorf("%w: access denied", ErrNotFound) }
Suggested fix: Move the DM arm out of the bypass so participation is required of everyone, matching requireChannelRead: hoist if aa.ChannelID != nil && aa.ChannelType == "dm" to just above if !isAdmin and 403 there when user == nil or IsDMParticipant is false, leaving the if !isAdmin block to cover only the unlinked/avatar and guild-channel arms.
Fixed: 8579cb5d · test Server/api/upload_handler_test.go · revert-proof pass
OC-0113 — medium — Selecting "Default" microphone never changes the capture device — the mute/unmute cycle re-acquires nothing
Client/src/lib/deviceManager.ts:71 · found 2026-08-13 · hunt 2026-08-13-postopt · lens explore-2
cycleMicForDeviceSwitch is the entire implementation of "switch back to the default input device", but it only calls setMicrophoneEnabled(false) then (true). On an already-published mic that is livekit's track.mute() → track.unmute(), and with the default stopMicTrackOnMute: false neither call touches the device: mute() skips the _mediaStreamTrack.stop() branch, and unmute() only restarts when stopOnMute || readyState === 'ended' || pendingDeviceChange. Nothing ever clears Room.options.audioCaptureDefaults.deviceId or LocalAudioTrack._constraints.deviceId, which a previous switchActiveDevice('audioinput', id) left as {exact: <old device>}. So the non-empty branch (room.switchActiveDevice) really switches, and the empty/"Default" branch silently does not.
Repro: Join a voice channel. Open Settings → Voice & Audio and pick a specific microphone (e.g. a USB headset); VoiceAudioTab.ts:404-407 saves the pref and calls switchInputDevice("usb-mic-id"), which reaches room.switchActiveDevice("audioinput", "usb-mic-id") and pins audioCaptureDefaults.deviceId = {exact: "usb-mic-id"}. Now select "Default" in the same dropdown: inputSelect.value is "", so switchInputDevice("") takes the else branch and only mutes/unmutes the existing publication. The UI and the audioInputDevice pref both read "Default", but the room keeps capturing from the USB headset for the rest of the session — unplug the headset and the mic dies rather than falling back. The same helper backs handleDeviceChange's device-removed fallback (deviceManager.ts:130), so that "switched to default" path cannot reach the default device either. The existing test tests/unit/device-manager.test.ts:231-237 ("re-enables microphone for empty deviceId (default fallback)") asserts only the setMicrophoneEnabled(false)/(true) call pair against a mocked Room, so it locks the mechanism, not the outcome the name claims.
Evidence: deviceManager.ts:71-79
private async cycleMicForDeviceSwitch(room: Room): Promise {
await room.localParticipant.setMicrophoneEnabled(false);
if (this.room !== room) return;
if (isMicPolicyGated()) { ...return; }
await room.localParticipant.setMicrophoneEnabled(true);
}
deviceManager.ts:167-171
if (deviceId) {
await room.switchActiveDevice("audioinput", deviceId);
} else {
await this.cycleMicForDeviceSwitch(room); // <-- no device reset at all
}
node_modules/livekit-client/dist/livekit-client.esm.mjs:19174 stopMicTrackOnMute: false, (OwnCord never overrides it — grep -rn stopMicTrackOnMute src/ is empty)
esm.mjs:29446-29448 setTrackEnabled(..., enabled=true) -> if (track) { yield track.unmute(); } (no createTracks, no new constraints)
esm.mjs:20966-20971 mute(): if (source === Microphone && this.stopOnMute && !isUserProvided) { this._mediaStreamTrack.stop(); } -> skipped
esm.mjs:20990-20993 unmute(): if (source === Microphone && (this.stopOnMute || readyState === 'ended' || this.pendingDeviceChange) ...) { yield this.restart(undefined, true); } -> all three false
esm.mjs:32852-32853 switchActiveDevice audioinput: _this3.options.audioCaptureDefaults.deviceId = deviceConstraint; (never reset by the cycle)
livekitSession.ts:1119-1122 on (re)connect the saved input is only applied when non-empty: if (savedInput !== "") { await localRoom.switchActiveDevice("audioinput", savedInput); } — so "" never resets it there either.
Suggested fix: Fix it once in the shared helper rather than in both callers: make cycleMicForDeviceSwitch reset the pinned constraint before the cycle by adding await room.switchActiveDevice("audioinput", "default", false); as its first statement (exact=false yields a plain 'default' string, i.e. an ideal constraint, matching livekit's own audioDefaults {ideal:'default'}, so it degrades gracefully where no 'default' id exists). That clears room.options.audioCaptureDefaults.deviceId and drives setDeviceId -> restartTrack on the live publication, so both switchInputDevice('') and the hot-swap fallback actually reach the default device; the existing mute/PTT gate at deviceManager.ts:74 and its two tests stay untouched.
Fixed: b1fb565 · test Client/tests/unit/device-manager.test.ts · revert-proof self-reported
OC-0114 — medium — In a group DM, one participant's decline silences every other participant's incoming ring — and never reaches the actual caller
Server/ws/handlers_call.go:84 · found 2026-08-13 · hunt 2026-08-13-postopt · lens explore-1
handleCallDeclineV2 addresses call_declined to every OTHER participant of the DM (RingTargets excludes only the sender), so fellow callees receive it alongside the ringer. The client's handler ws.on("call_declined", p => ringCtrl?.cancel(p.channel_id)) (Client/src/pages/MainPage.ts:579-581) cancels purely on channel_id and ignores from_user, so it cannot tell "the caller hung up" from "another callee declined". Meanwhile the caller itself never received call_incoming (RingTargets excludes the sender at handlers_call.go:52 / dm.go:377-380), so ringCtrl.current() is null on the caller and cancel() is a no-op there — the decline's stated purpose ("so a ringing client can stop ringing before the 30s timeout") is unreachable, and its only observable effect is to kill the other callees' rings.
Repro: Group DM C with A, B, D. A opens C and clicks the call button → voice_join(C) + call_ring(C). Server sends call_incoming{channel_id:C, from_user:A} to B and D; both banners appear and both chimes start. B clicks Decline → call_decline{channel_id:C}. Server RingTargets(B,C)=[A,D] and sends call_declined{channel_id:C, from_user:B} to A and D. D's ring state is {channelId:C, fromUserId:A}, so cancel(C) matches → D's banner is torn down and the chime stops; D can no longer answer a call A is still sitting in. A, having no ring state, ignores the frame entirely — so the one client the decline was meant for is the one it does nothing to.
Evidence: handlers_call.go:79-93:
targets, err := d.DMSvc.RingTargets(ctx, info.UserID, declineCmd.ChannelID())
payload := buildCallSignal(MsgTypeCallDeclined, declineCmd.ChannelID(), info.UserID, info.Username)
for _, pid := range targets { events = append(events, CallSignalEvent{eventType: MsgTypeCallDeclined, targetUserID: pid, payload: payload}) }
MainPage.ts:578-582:
ws.on("call_declined", (payload) => { ringCtrl?.cancel(payload.channel_id); })
call-ring.ts:114-117:
function cancel(channelId: number): void { if (state === null || state.channelId !== channelId) return; stopRinging(); }
The payload carries from_user (messages.go:310-314, types.ts:571-575) but the client never reads it. No test locks this: tests/unit/call-ring.test.ts:158-186 only covers channel-id scoping.
Suggested fix: Guard the client handler by ringer identity instead of channel alone: in MainPage.ts:579-581, const r = ringCtrl?.current(); if (r && payload.from_user === r.fromUserId) ringCtrl?.cancel(payload.channel_id);. That keeps the legitimate glare case (both users ringing each other, the peer declines) working and stops a fellow callee's decline from cancelling an unrelated ring. Server fan-out can stay as-is — it has no call state to target with.
Fixed: db0275a2 · test Client/tests/unit/main-page.test.ts · revert-proof pass
OC-0115 — medium — Link-preview fetch buffers the whole response body with no timeout and no size cap — the documented 50 KB bound is applied after the bytes are already in memory
Client/src/components/message-list/embeds.ts:202 · found 2026-08-13 · hunt 2026-08-13-postopt · lens explore-2
The 5 s AbortController timer is cleared at line 182, the moment response headers arrive, so await res.text() at line 202 runs with the abort signal already disarmed and reads the body to completion before html.slice(0, 50_000) is applied. The slice is therefore not a memory bound at all (the comment at line 49 and line 203 both claim it is), and there is no timeout covering the body phase.
Repro: Any message author posts a plain https URL (no code fence, no masked-link syntax) pointing at a host that answers Content-Type: text/html and then streams an endless / multi-GB body. media.ts:549 calls renderGenericLinkPreview() automatically for every such URL when showLinkPreviews is on (default true, media.ts:29), so every client that merely renders the message issues the fetch with no user interaction. tauriFetch resolves as soon as headers arrive, clearTimeout(timer) disarms the only abort, and res.text() accumulates the stream forever: the renderer's memory climbs without limit and the in-flight entry in ogInFlight never settles, so the card stays on its "loading" fallback title permanently. Ten such links in one channel = ten simultaneous unbounded reads.
Evidence: const timer = setTimeout(() => controller.abort(), 5000);
...
const res = await tauriFetch(url, fetchOpts);
clearTimeout(timer); // <-- signal disarmed once headers land
...
const html = await res.text(); // unbounded, untimed
const meta = parseOgTags(html.slice(0, 50_000)); // "memory bound" applied too late
Suggested fix: Move the disarm past the body read instead of adding a second timer: drop clearTimeout(timer) at line 182 and wrap the response handling so it runs after const html = await res.text(); (or put clearTimeout(timer) in a finally on the async IIFE). That makes the existing 5 s AbortController cover the body phase, and the stream's abort listener errors res.text() and drops the body on the Rust side.
Fixed: b1fb565 · test Client/tests/unit/embeds.test.ts · revert-proof self-reported
OC-0116 — medium — A failed TOTP verify tears down the TOTP overlay, so the code cannot be re-entered
Client/src/pages/connect-page/LoginForm.ts:667 · found 2026-08-13 · hunt 2026-08-13-postopt · lens explore-3
handleTotpSubmit's catch calls transitionTo("error", …). transitionTo runs updateTotpOverlay(), which adds totp-overlay--hidden for every state that is not "totp". A rejected verification therefore closes the code prompt and drops the user back on the login form; there is no showTotp() call on any error path, so the second factor is unreachable without re-submitting the password.
Repro: showTotp() → overlay visible. Enter a code the server rejects; onTotpSubmit rejects; the catch sets formState="error"; updateTotpOverlay hides the overlay and updateFormInputsDisabled re-enables host/username/password. The error banner (LoginForm.ts:191, a child of the form panel behind the overlay) is the only feedback, and the code input is gone. tests/e2e/totp-flow.spec.ts:99 asserts only that the banner becomes visible — nothing locks the overlay staying open or a retry succeeding.
Evidence: LoginForm.ts:663-671:
try { await onTotpSubmit(code); }
catch (err) {
const message = err instanceof Error ? err.message : "Verification failed.";
transitionTo("error", message); // ← state leaves "totp"
} finally { totpSubmitBtn.disabled = false; setText(totpSubmitBtn, "Verify"); }
LoginForm.ts:519-527:
function updateTotpOverlay(): void {
if (formState === "totp") { totpOverlay.classList.remove("totp-overlay--hidden"); ... }
else { totpOverlay.classList.add("totp-overlay--hidden"); }
}
The finally re-enables the Verify button on an overlay that is already hidden.
Suggested fix: Keep the overlay up while a challenge is outstanding instead of tying it to formState alone: add a totpPending flag set in showTotp() and cleared in handleTotpCancel() (and on successful auth), then change updateTotpOverlay's condition to if (formState === "totp" || (formState === "error" && totpPending)), and only clear/focus totpInput on the fresh formState === "totp" entry so a retry keeps its context. This is the single shared point both the click and Enter paths route through. Pair it with retaining pendingTotpPartialToken in main.ts (move the clear out of finally into the success branch plus cancel), otherwise the re-opened prompt hits the empty-token guard at main.ts:520.
Fixed: 8787b906 · test Client/tests/unit/login-form-totp-retry.test.ts · revert-proof pass
OC-0117 — medium — Channel create/edit/delete modals lock up permanently on an API failure — the caller swallows the rejection the modal needs to re-enable its button
Client/src/pages/main-page/SidebarArea.ts:280 · found 2026-08-13 · hunt 2026-08-13-postopt · lens explore-3
CreateChannelModal (and EditChannelModal/DeleteChannelModal) disable their submit button, relabel it "Creating…"/"Saving…"/"Deleting…", and rely on await onCreate(...) REJECTING to restore the button and render the inline error. SidebarArea's callbacks catch the API error themselves and only show a toast, so the promise resolves normally: the modal's own catch never runs, the button stays disabled with the in-flight label forever, and the modal's errorEl (data-testid="channel-create-error") is dead code on every real code path. The user cannot retry after a recoverable error (duplicate name, 403, transient network) without cancelling and losing everything typed.
Repro: Log in as an admin, click "+" on a category, type a channel name that already exists (or disconnect the network), press "Create Channel". The server returns an error -> a toast appears, but the button stays greyed out reading "Creating..." and no inline error is shown. Fixing the name and clicking again does nothing; only Cancel/Escape/X recovers, discarding the form.
Evidence: SidebarArea.ts:275-284 (create)
onCreate: async (data) => {
try { await api.adminCreateChannel(data); modal.destroy?.(); activeModal = null; }
catch (err) {
const msg = err instanceof Error ? err.message : "Failed to create channel";
getToast()?.show(msg, "error"); // <- swallowed, never rethrown
}
},
CreateChannelModal.ts:163-177
createBtn.setAttribute("disabled", "true");
setText(createBtn, "Creating...");
try { await onCreate({...}); }
catch (err) { errorEl.style.display = "block"; ...; createBtn.removeAttribute("disabled"); setText(createBtn, "Create Channel"); }
Identical pattern at SidebarArea.ts:314 (onSave -> EditChannelModal.ts:377-384) and SidebarArea.ts:337 (onConfirm -> DeleteChannelModal.ts:91-98).
tests/unit/sidebar-area.test.ts:1761 mocks createCreateChannelModal, so nothing locks the integrated behavior; tests/unit/create-channel-modal.test.ts only exercises the modal with a rejecting onCreate.
Suggested fix: Preserve the modals' reject-to-recover contract in the shared caller: in SidebarArea.ts's three catch blocks, keep the toast and add throw err; (or drop the try/catch entirely and let the modal render the inline error). One line per callback; the six sidebar-area.test.ts cases that await modalCallArgs.onCreate/onSave/onConfirm(...) must then be updated to await expect(...).rejects.toThrow(...). Restoring the button in a finally inside each modal is the alternative but leaves errorEl dead code and touches three files instead of one.
Fixed: b1fb565 · test Client/tests/unit/sidebar-area.test.ts · revert-proof self-reported
OC-0118 — low — Identity-keyring account namespace collides between a legacy host-only entry and a scoped host+userId entry, letting one server's voice identity private key be adopted on another server
Client/src/lib/identity.ts:211 · found 2026-08-13 · hunt 2026-08-13-postopt · lens voice-e2ee
The B3-3 per-user scope is folded into the single opaque host field as ${host}:${userId}, and the Rust side turns that into the keyring account identity:{host}. A host that carries an explicit port therefore produces exactly the same account string as the pre-B3-3 legacy (host-only) entry of a different server: identityScopeKey("chat.example", 8443) -> account identity:chat.example:8443, which is byte-identical to identity_account("chat.example:8443"). loadOrGenerateIdentityKeyPair reads that account directly, so it silently adopts the other server's long-term identity private key instead of minting a fresh one — and ensureIdentityKeyPublished then publishes that key's public half to the second server, linking the two identities. Host scoping exists precisely to prevent one host's identity key ever being used on another.
Repro: Same desktop install. (1) Sign in to a self-hosted server reachable as chat.example:8443 on a build that predates the host+user scoping — the identity private key is stored under keyring account identity:chat.example:8443. (2) Sign in to a different server reachable as chat.example (port 443) as the user whose id is 8443. loadOrGenerateIdentityKeyPair("chat.example", 8443) computes scope chat.example:8443, loadIdentityKey reads account identity:chat.example:8443, importIdentityKeyPair succeeds on the first server's blob, and the client signs its voice announces on chat.example with chat.example:8443's identity key while ensureIdentityKeyPublished PATCHes that public key onto chat.example.
Evidence: Client/src/lib/identity.ts:211-213
function identityScopeKey(host: string, userId: number): string {
return ${host}:${userId};
}
Client/src/lib/identity.ts:298-299
const scope = identityScopeKey(host, userId);
const stored = await loadIdentityKey(scope); // -> invoke("load_identity_key", { host: scope })
Client/src-tauri/src/credentials.rs:46-48
fn identity_account(host: &str) -> String {
format!("identity:{host}")
}
Client/src-tauri/src/credentials.rs:227-232 (load_identity_key -> secret_store::get(&app, &identity_account(&host)))
Suggested fix: One line in identityScopeKey (identity.ts:211-213): use a delimiter that cannot appear in a valid host, e.g. return ${userId}@${host}; (isValidHost forbids '@' and '/'), which makes a scoped key structurally unable to equal any legacy host-only account. Existing scoped accounts re-mint once — the same one-time re-verify the B3-3 comment at 187-189 already accepts; add a scope-to-scope migration only if that churn matters.
Fixed: 8787b906 · test Client/tests/unit/identity.test.ts · revert-proof pass
OC-0119 — low — Group-DM creation only block-checks the creator against each recipient, so a third party can force two users who blocked each other into a shared room
Server/service/dm.go:250 · found 2026-08-13 · hunt 2026-08-13-postopt · lens api-authz
CreateGroupDM documents the creation-time check as answering "may these two be in a room together", and requireDMNotBlocked deliberately exempts group DMs from every downstream sink (send, edit, react, pin, typing, ring) on the strength of that. But the loop only evaluates IsEitherBlocked(creator, recipient) — never recipient↔recipient. Since group DMs are exempt from the send-time block gate, the two mutually-blocked members can then message each other freely, which is exactly what the block is supposed to prevent.
Repro: C blocks B (PUT /api/v1/blocks/{B}). A blocks nobody. A sends POST /api/v1/dms/group {"recipient_ids":[B,C]}. Both IsEitherBlocked(A,B) and IsEitherBlocked(A,C) are false, so the group is created with A, B and C. B now sends a message into that channel: checkSendPermission takes the DM branch, IsDMParticipant(B) is true, requireDMNotBlocked short-circuits on IsGroupDM, and the message is delivered to C. C receives messages from a user they blocked. Existing tests only cover creator↔recipient (api/dm_group_handler_test.go:158 TestCreateGroupDM_BlockerCannotAddBlocked, :172 TestCreateGroupDM_BlockedCannotAddBlocker), so nothing locks the recipient↔recipient case.
Evidence: service/dm.go:239-256
for _, rid := range unique {
user, err := s.st.GetUserByID(ctx, rid)
...
blocked, err := s.st.IsEitherBlocked(ctx, userID, rid) // creator vs recipient ONLY
...
if blocked { return nil, fmt.Errorf("%w: cannot add a blocked user to a group DM", ErrForbidden) }
}
service/message_perms.go:118-122 (requireDMNotBlocked)
isGroup, gErr := st.IsGroupDM(ctx, channelID)
if gErr == nil && isGroup { return nil } // groups skip the block gate entirely
Suggested fix: Check every pair, not just the creator's, in the one place that already owns the gate: build participantIDs := append([]int64{userID}, unique...) before the validation loop and run IsEitherBlocked over each i<j pair (n <= db.MaxGroupDMParticipants, so the O(n^2) scan is trivial), returning the same ErrForbidden. That subsumes the existing creator-vs-recipient check rather than adding a second one.
Fixed: 8787b906 · test Server/api/dm_group_handler_test.go · revert-proof pass
OC-0120 — low — Reaction-users endpoint has no soft-deleted-message guard, unlike its sibling in the same file
Server/service/message_reactions.go:42 · found 2026-08-13 · hunt 2026-08-13-postopt · lens api-authz
GetReactionUsers validates emoji shape, read access and channel ownership of the message, but never checks msg.Deleted. Its siblings do: handleReaction (same file, line 101-103) refuses a deleted message, GetMessagesAround refuses one as ErrNotFound, and handleServeFile was specifically hardened so a deleted message's attachments stop being servable. So a message the client renders as a tombstone still exposes who reacted to it, forever, by direct URL.
Repro: In channel 5, user X posts message 42 and several members react with 👍. A moderator deletes message 42 (soft delete; history and GET /channels/5/messages now omit it and clients render a tombstone). Any member with READ_MESSAGES on channel 5 then calls GET /api/v1/channels/5/messages/42/reactions/%F0%9F%91%8D/users and still receives the full reactor list with 200 OK, because requireChannelRead passes and the deleted flag is never consulted.
Evidence: service/message_reactions.go:41-44 (GetReactionUsers)
msg, err := s.st.GetMessage(ctx, msgID)
if err != nil || msg == nil || msg.ChannelID != channelID {
return nil, fmt.Errorf("%w: message not found", ErrNotFound)
}
// no msg.Deleted check
service/message_reactions.go:97-103 (handleReaction, same file)
msg, err := s.st.GetMessage(ctx, msgID)
if err != nil || msg == nil { ... }
if msg.Deleted {
return nil, fmt.Errorf("%w: cannot react to deleted message", ErrBadRequest)
}
service/message_query.go:151-153 (GetMessagesAround)
if msg == nil || msg.ChannelID != channelID || msg.Deleted {
return nil, fmt.Errorf("%w: message not found in this channel", ErrNotFound)
Suggested fix: Add || msg.Deleted to the existing not-found condition at service/message_reactions.go:42, so it reads if err != nil || msg == nil || msg.ChannelID != channelID || msg.Deleted. One clause in the service function covers both API and WS callers.
Fixed: 8787b906 · test Server/service/message_reaction_users_test.go · revert-proof pass
OC-0121 — low — Message jumps and permalinks into a DM fail with "That channel isn't available" until the DM has been opened once this session
Client/src/lib/channel-navigation.ts:36 · found 2026-08-13 · hunt 2026-08-13-postopt · lens client-state
findChannelById and navigateToChannel gate on channelsStore.channels, but a DM's row in that store is synthesized on open by addDmToChannelsStore — the ready payload never carries DM rows, and dmStore is the client's only record of DM membership until the user clicks the conversation. Every jump affordance (global search hit, owncord://message/... permalink, pinned/reply jump) routes through MessageJump.jumpTo, whose very first check is findChannelById(channelId) === null, so a legitimate, visible DM is rejected as invisible.
Repro: Sign in fresh (so ready populates dmStore with, say, DM channel 50, and channelsStore holds zero type: "dm" rows). Without clicking that DM, open the search overlay (it is wired to jumper.jumpTo via ChatArea.ts:99-104) and search for a word that appears in DM 50. The server returns the hit (GetAccessibleChannelIDs unions the user's DM ids). Click it: jumpTo(50, msgId) calls findChannelById(50), channelsStore has no entry for 50, so it toasts "That channel isn't available" and returns false — getMessagesAround is never called. Identical failure for an owncord://message/50/<id> deep link (main.ts:833 → jumpToMessage) and for a #-less permalink chip. Clicking the DM in the sidebar once (selectDmConversation → addDmToChannelsStore) makes the exact same jump work. tests/unit/message-jump.test.ts:221 only locks the case where the id is absent from both stores, so this path is not test-covered.
Evidence: channel-navigation.ts:36-39
export function findChannelById(channelId: number): { id: number; name: string } | null {
const ch = channelsStore.getState().channels.get(channelId);
return ch === undefined ? null : { id: ch.id, name: ch.name };
}
channel-navigation.ts:17-18
export function navigateToChannel(channelId: number): void {
if (!channelsStore.getState().channels.has(channelId)) return;
MessageJump.ts:75-78
if (findChannelById(channelId) === null) {
showToast("That channel isn't available", "info");
return false;
}
dispatcher.ts:444-448 (the codebase's own statement of the invariant)
// Synthesize the channelsStore mirror row before activating: it is
// only ever created by addDmToChannelsStore (on open, via
// selectDmConversation), so a DM present in dmStore from ready
// but never opened this session has none
Server/service/message_perms.go:42-46 (search covers DMs)
// Also include DM channels the user participates in.
dmIDs, err := s.st.GetUserDMChannelIDs(ctx, userID)
if err == nil { ids = append(ids, dmIDs...) }
Suggested fix: Make the shared resolver DM-aware instead of patching each affordance: in channel-navigation.ts, when channelsStore has no row for channelId, look the id up in dmStore — findChannelById returns { id, name: dmDisplayName(dm) }, and navigateToChannel calls addDmToChannelsStore(dm) before setActiveChannel so ChannelController can resolve it (dispatcher.ts:449 already crosses the same lib->pages import boundary for exactly this reason).
Fixed: c3837fa · test Client/tests/unit/channel-navigation.test.ts · revert-proof self-reported
OC-0122 — low — dm_channel_close fallback activates a DM without clearing its dmStore unread badge, leaving a permanent phantom badge on the conversation being read
Client/src/lib/dispatcher.ts:450 · found 2026-08-13 · hunt 2026-08-13-postopt · lens client-state
Every other "open this DM" path pairs activation with clearDmUnread (selectDmConversation, navigateToChannel, markChannelRead), because a DM's badge lives in dmStore while setActiveChannel only clears the channelsStore mirror. The dispatcher's close-fallback calls bare setActiveChannel, so dmStore's unreadCount/mentionCount survive — and because the dispatcher then treats that DM as the active one (isDmActive → updateDmLastMessagePreview, no increment), the stale count is frozen and never clears for the rest of the session.
Repro: Two open DMs: A (channelId 50, active) and B (channelId 60, unreadCount 3). From another signed-in device, close DM A. The server sends dm_channel_close{channel_id:50}; closeDmLocally(50, fallback) sees A was active and runs the fallback, which activates B via setActiveChannel(60). dmStore's row for 60 still has unreadCount: 3, so buildDmConversations/SidebarDmSection.renderDmListItems (line 97: if (dm.unreadCount > 0)) keep rendering a red "3" on the conversation now filling the screen, and it also inflates the DIRECT MESSAGES header total (SidebarDmSection.ts:128). New messages arriving in B take the isDmActive branch, so the count never moves; hasUnread(60) stays true, so "Mark All as Read" fires a redundant mark_read for it. tests/unit/dispatcher.test.ts:2931 exercises this fallback with unreadCount: 0 on both DMs, so the behavior is not locked.
Evidence: dispatcher.ts:449-450
addDmToChannelsStore(remaining[0]!);
setActiveChannel(remaining[0]!.channelId);
contrast SidebarDmHelpers.ts:53-57 (the sibling path)
clearDmUnread(dmChannel.channelId);
addDmToChannelsStore(dmChannel);
setActiveChannel(dmChannel.channelId);
contrast SidebarArea.ts:437-440 (the local close path)
function fallBackFromDm(): void {
const remaining = dmStore.getState().channels;
if (remaining.length > 0) { selectDmConversation(remaining[0]!, dmDeps); return; }
dispatcher.ts:502-518 (why the count then freezes)
if (isOwnMessage || isDmActive || ws.isReplaying()) {
updateDmLastMessagePreview(...) // no unread increment
Suggested fix: Replace the two lines with addDmToChannelsStore(remaining[0]!); navigateToChannel(remaining[0]!.channelId); — navigateToChannel is already the shared 'open this channel' entry point and does setActiveChannel + clearUnread + clearDmUnread in one call (a bare clearDmUnread(remaining[0]!.channelId) before line 450 is the even smaller equivalent).
Fixed: c3837fa · test Client/tests/unit/dispatcher.test.ts · revert-proof self-reported
OC-0123 — low — markAllRead's paced tail marks messages that arrived after the click as read, destroying a genuinely-new unread badge
Client/src/lib/read-state.ts:118 · found 2026-08-13 · hunt 2026-08-13-postopt · lens client-state
markAllRead snapshots the unread set at click time but defers most of the sends by up to ceil(n/4) * 1100 ms. Each deferred timer calls markChannelRead(id) unconditionally, which both sends mark_read (advancing the server's read state to the channel's current tail) and calls clearUnread/clearDmUnread locally. A message that arrives in one of those channels during the pacing window is therefore marked read and its badge wiped, even though the user never saw it and it postdates the action they took.
Repro: Have 6 unread channels (ids 1..6) and click "Mark All as Read". Channels 1-4 are sent synchronously; 5 and 6 are queued for t+1100 ms. At t+500 ms a new message arrives in channel 6: the dispatcher's chat_message handler calls incrementUnread(6) and the sidebar badge shows 1. At t+1100 ms the queued timer fires markChannelRead(6), which sends mark_read for channel 6 (advancing the server read state past the new message) and calls clearUnread(6). The message is gone from every unread surface and the server agrees it was read, though it arrived after the click and was never displayed. With ~100 unread conversations the window stretches to ~27 s. tests/unit/read-state.test.ts:156 locks the pacing itself but never delivers a message during the window.
Evidence: read-state.ts:112-120
const ids = unreadChannelIds();
for (const [i, id] of ids.entries()) {
const delay = Math.floor(i / MARK_ALL_READ_BURST_SIZE) * MARK_ALL_READ_BURST_INTERVAL_MS;
if (delay === 0) { markChannelRead(id); }
else { pendingMarkAll.push(setTimeout(() => markChannelRead(id), delay)); }
}
read-state.ts:42-51 (no re-check of current unread state)
export function markChannelRead(channelId: number): void {
const known = ...;
if (!known) return;
sender?.(channelId);
clearUnread(channelId);
clearDmUnread(channelId);
}
read-state.ts:103-105 (the stated intent this breaks)
// Each channel's local badge is cleared at the moment its own frame actually
// goes out, not up front, so a channel whose send hasn't fired yet still
// shows unread rather than lying about it.
Suggested fix: In markAllRead, snapshot each id's current lastMessageId alongside the id, and in the deferred callback skip markChannelRead when the channel's lastMessageId has moved (the new message is genuinely unread and keeping its badge is the safer side of the tradeoff). ~4 lines, entirely inside markAllRead; do not change markChannelRead, whose synchronous callers want the unconditional advance.
Fixed: c3837fa · test Client/tests/unit/read-state.test.ts · revert-proof self-reported
OC-0124 — low — hidePreview overwrites the pending animation timer without clearing it, orphaning timers that later tear down a freshly reopened preview and strand its media-track listeners
Client/src/lib/streamPreview.ts:273 · found 2026-08-13 · hunt 2026-08-13-postopt · lens concurrency
hidePreview clears only state.debounce (line 249) and then blindly assigns state.animation = animTimer (line 273). The animation slot may already hold a live timer — either stopPreviewDelayed's 150 ms grace timer (line 315) or a prior hidePreview's 200 ms removal timer — and that timer is never cancelled, so it keeps running with no handle. When an orphaned removal timer fires it runs previewTimers.delete(row) (line 269) against whatever state a later hover installed, which both destroys that state's trackCleanup handle and makes the abort handler's clearPreviewState (lines 79-80) a no-op for the row.
Repro: Voice sidebar, remote participant with a live camera. t=0: hover the row -> mouseenter -> startPreview -> after the 300 ms debounce showPreview inserts .vu-preview, adds ended/mute listeners to the remote MediaStreamTrack and stores trackCleanup on the state (lines 148-158). t=400: move the pointer off the row -> mouseleave -> stopPreviewDelayed arms T1 (150 ms) in state.animation. t=450 (inside that window): scroll the voice list, or Tab away (focusout) -> hidePreview runs, does NOT clear T1, and writes T2 (200 ms removal) into state.animation. t=550: T1 fires, the preview is not :hover, so it calls hidePreview again -> writes T3 (200 ms) into state.animation, orphaning T2. t=650: T2 fires -> removePreviewDom + previewTimers.delete(row). t=660: hover the row again -> startPreview's clearPreviewState finds no state (already deleted), installs a fresh state with a 300 ms debounce. t=750: the orphaned T3 fires -> previewTimers.delete(row) removes that brand-new state. t=960: the debounce fires and showPreview builds a new
Evidence: hidePreview (246-275):
const state = previewTimers.get(row);
if (state !== undefined) {
clearTimeout(state.debounce); // <- only debounce is cleared
if (state.trackCleanup !== null) { ... }
}
...
const animTimer = window.setTimeout(() => {
removePreviewDom(row);
previewTimers.delete(row);
}, 200);
if (state !== undefined) {
state.animation = animTimer; // line 273: overwrite, no clearTimeout(state.animation)
}
stopPreviewDelayed (311-330) writes the same slot:
clearTimeout(state.animation);
state.animation = window.setTimeout(() => { ... hidePreview(row); }, 150);
Both other entry points call hidePreview directly without touching state.animation:
row.addEventListener("focusout", stopPreview, { signal }); // line 338 -> hidePreview
attachScrollCollapse -> hidePreview(row as HTMLElement); // line 358
Suggested fix: In hidePreview, cancel the pending animation timer before overwriting it: add clearTimeout(state.animation); immediately after clearTimeout(state.debounce); at line 249. One line in the shared function covers every entry path (scroll collapse, focusout, delayed stop, preview mouseleave), guarantees at most one live animation timer per state, and keeps that timer cancellable by clearPreviewState/startPreview. clearTimeout(0) on the initial value is a harmless no-op.
Fixed: c3837fa · test Client/tests/unit/stream-preview.test.ts · revert-proof self-reported
OC-0125 — low — Voice & Audio settings tab registers a new permanent abort listener on the overlay-lifetime AbortSignal on every build, pinning every previously built tab
Client/src/components/settings/VoiceAudioTab.ts:490 · found 2026-08-13 · hunt 2026-08-13-postopt · lens lifecycle
buildVoiceAudioTabInner adds an "abort" listener to the SettingsOverlay-lifetime AbortSignal each time the tab is (re)built, with no {once}, no removeEventListener, and no dedupe. The signal only fires when the whole overlay is destroyed, so the listeners accumulate for the MainPage session and each closure retains that build's entire DOM subtree (selects, sliders, toggles, preview
Repro: Open Settings, select "Voice & Audio", close Settings, reopen it (SettingsOverlay.hide() sets contentLive=false; show() calls renderActiveTab() -> voiceTab.build() -> buildVoiceAudioTabInner). Repeat N times: SettingsOverlay's single AbortController now carries N abort listeners, and the N-1 detached tab subtrees (each with a