fix: correctness fixes from the 2026-08-20 bug hunt (#1398)

* fix(identity): 2 defect(s) (OC-0192, OC-0197)

OC-0192: bound raw display_name/about/avatar bytes before the quadratic
fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile.

OC-0197: sanitize display_name before validateDisplayName so an
HTML-entity-encoded bidi override (e.g. "‮") can no longer pass
validation as ASCII and be decoded into the real character on the way to
storage.

* fix(ws): 1 defect(s) (OC-0196)

A transient DB error during WebSocket auth (session or user lookup) was
collapsed into the terminal auth_error frame, which the client treats as
non-recoverable: it stops reconnecting and clears stored credentials. A
sub-second SQLite hiccup therefore force-logged-out every reconnecting
client with a perfectly valid session. Send a non-terminal INTERNAL error
frame instead so normal backoff/reconnect retries.

* fix(api): 1 defect(s) (OC-0198)

* fix(ws): 1 defect(s) (OC-0200)

normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the
trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's
normalization order. Without the unwrap, every cert-tofu host equality guard
took the "unrelated host" branch for bracketed-IPv6 servers.

* fix(api): 1 defect(s) (OC-0202)

* fix(admin): 1 defect(s) (OC-0203)

Channel permission override handlers applied requireGrantableOverride only
to the bits being written, so an all-zero PUT or a DELETE could clear a
deny bit the actor's own role does not hold — EffectivePerms =
(rolePerm &^ deny) | allow makes removing a deny an escalation. Both the
role-layer and per-user handlers now check the guard against the bits
already on the row.

* fix(client): 1 defect(s) (OC-0205)

* fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235)

* fix(client): 1 defect(s) (OC-0208)

* fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213)

OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce
runs, so the replay cannot overwrite the peer's displayed verification
status/session fingerprint with the retired key's before being rejected.

OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it
after a successful rePinPeerIdentity, so re-pinning actually restores the
peer for the live call instead of clearing the badge and leaving them
un-keyed (a mid-call peer never re-announces on its own).

OC-0213: skip retiring a departing peer's key when the local voice roster
still lists them as present — a rejoin announce published straight into
the send queue can overtake the buffered, stale voice_leave, and retiring
a still-live key would reject every later genuine re-announce as a replay.

* fix(ws): 1 defect(s) (OC-0211)

* fix(identity): 1 defect(s) (OC-0214)

The delete-account admin guard counted remaining admins with a raw
`banned = 0` filter, so an admin whose temporary ban had already lapsed
was treated as unusable. Use the shared notBannedClause, appended outside
the Sprintf format string because its strftime verbs (%Y, %H) would
otherwise be parsed as fmt directives.

* fix(client): 1 defect(s) (OC-0215)

* fix(voice): 1 defect(s) (OC-0216)

* fix(client): 1 defect(s) (OC-0217)

* fix(voice): 1 defect(s) (OC-0219)

rollbackVoiceJoin cleared the client's in-memory voiceChID but left its
VoiceTopic subscription in place, so a socket whose join failed after
voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for
the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching
every other path that takes a client out of voice while its WS stays up.

* fix(client): 2 defect(s) (OC-0220, OC-0224)

dmDisplayName: a group DM whose other members have all left keeps a live
is_group row, but the server leaves `recipient` zero-valued, so the empty
username fell through as a blank label. Fall back to a non-empty placeholder.

updateDmLastMessage: a queued chat_message redelivered for an id already
reflected in the `ready` snapshot double-counted the unread badge. Only
increment when the message id advances past lastMessageId.

* fix(client): 1 defect(s) (OC-0221)

Cap queued attachments at the server's 10-attachment limit in the message
composer. Past that the server rejects the whole chat_send frame as a
generic parse error, orphaning already-uploaded attachments; refusing
before the upload starts keeps composer state and the send in sync.

* fix(ws): 1 defect(s) (OC-0222)

handleReconnect built the resume auth_ok before applyConnectStatus settled
c.user.Status, so a resumed client was told its disconnect-time status
(routinely "offline") instead of the status it was coming online as.
Move applyConnectStatus ahead of reconnectWriteReplay, matching
handleFreshConnect's ordering.

* fix(mentions): 1 defect(s) (OC-0223)

* fix(admin): 1 defect(s) (OC-0225)

* fix(client): 1 defect(s) (OC-0226)

* fix(client): 1 defect(s) (OC-0228)

* fix(client): 1 defect(s) (OC-0230)

Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list.

* fix(voice): 1 defect(s) (OC-0231)

* fix(client): 1 defect(s) (OC-0232)

Reduce Motion toggle wrote the reduced-motion class directly, fighting the
OS-sync media-query listener that owns it when Sync with OS is on. Route the
side effect through syncOsMotionListener so whichever source owns the class
re-derives it.

* fix(client): 1 defect(s) (OC-0233)

notifyIncomingMessage titled the desktop notification with the raw
payload username, so the popup named the sender differently from the
message row it points at. Resolve the author the same way the message
list does (resolveAuthor over the live membersStore, then
resolveDisplayName).

* fix(client): 1 defect(s) (OC-0234)

* fix(client): 1 defect(s) (OC-0236)

* fix(ws): 1 defect(s) (OC-0237)

* fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)

* fix(identity): 1 defect(s) (OC-0195)

Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.

* fix(dm): 1 defect(s) (OC-0199)

handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed.

* fix(voice): 1 defect(s) (OC-0206)

vad-worklet.js gate timing constants were copied from the setTimeout
fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs
once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext).
The mic gate therefore closed ~6x faster than intended (~32ms of silence
instead of ~200ms), with the startup grace and RMS post interval off by
the same factor. Scale the frame counts to render quanta.

* fix(client): 1 defect(s) (OC-0229)

* test(client): assert the real TOFU re-pin outcome and make the pin mock faithful

The e2e journey test asserted that "Trust New Key" makes the peer's verify
badge disappear. That is the behaviour OC-0212 identifies as the defect: a
mid-call peer never re-announces, so clearing the badge left the peer
un-keyed for the rest of the call with nothing on screen. Re-pinning now
replays the announce that was blocked as a mismatch and re-verifies it
against the pin just stored, so assert the peer actually lands verified.

The mock's store_identity_pin was a no-op recorder while get_identity_pin
served a static seed map, so the replayed announce re-read the stale pin and
re-failed — a mismatch the real keyring never produces. Back the pins with a
mutable map so a write is visible to the next read. The unreadable-store
(DC-08) and reject-keeps-blocked paths are unchanged and still pass.

* fix(dm): 1 defect(s) (OC-0194)

Add regression tests pinning the raw-byte bound on group DM names, for
both CreateGroupDM and RenameGroupDM.

The Server/service/dm.go source fix for OC-0194 already landed in
bdbd5ac (fix(identity): 1 defect(s) (OC-0195)), which generalized the
guard into cleanTextBounded and applied it to the group DM name paths
alongside the profile fields. This commit therefore carries the OC-0194
tests only; dm.go is unchanged.

Revert-proof: with dm.go restored to bdbd5ac^ (cleanText before the
rune-count check) both new tests fail — CreateGroupDM returns "recipient
not found" after 222ms and RenameGroupDM accepts the name after 251ms,
against a 150ms budget. With the fix in place both pass in 0.03s.

* fix(ws): 1 defect(s) (OC-0210)

* chore(findings): record the 2026-08-20 hunt's 46 findings as fixed

Appends OC-0192..OC-0237 from the 2026-08-20 converging hunt and marks each
fixed with its commit and the test that pins it. Pre-existing records are
byte-identical; nextId moves 192 -> 238 so the next hunt cannot collide with
these ids.

Every fix was independently revert-proofed: the commit's own source diff is
reverse-applied, its test must go red, and must return green once restored.
43 of 46 carry revertProof "pass" from that mechanical run. Three could not be
checked at file level and were proved by hand at hunk level instead, recorded
as "pass (hand-proved)": OC-0200, whose ws.ts edit no longer reverse-applies
because the merge kept main's equivalent implementation; OC-0215, whose Rust
tests live in-file under #[cfg(test)]; and OC-0194, which stacks on a helper
introduced by an earlier commit. No fix was found to rest on a vacuous test.

OC-0200 additionally carries a note: main fixed that same normalizer
independently while this branch was in flight, so the branch is no longer the
only thing closing it.

* docs: record the dm_channel_open emission on 1:1 DM creation

POST /api/v1/dms now emits dm_channel_open to the recipient when it creates a
channel (it previously emitted nothing on that path), so api.md states it the
way the sibling DM endpoints already state theirs.

The channels/members/DMs UX spec claimed the server broadcast the event "to
both parties" on this flow. That was never true — nothing was broadcast before,
and now only the recipient is sent it; the creator learns the channel from the
response body. This doc lists dispatcher.ts, dm.store.ts, ChannelSidebar.ts,
service/channel.go and dm.go among its sources of truth, all touched here, so
it is corrected in the same change per its maintenance rule.

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-08-20 20:45:30 +02:00
committed by GitHub
co-authored by Claude
parent d880b64d64
commit 5202e3fe1e
91 changed files with 6345 additions and 284 deletions
+1314 -1
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -16,14 +16,19 @@ class VadProcessor extends AudioWorkletProcessor {
constructor() {
super();
this._threshold = 0.05;
this._gateOnFrames = 12; // ~200ms of silence before gating
this._gateOffFrames = 2; // ~33ms of speech before ungating
// process() runs once per 128-sample render quantum (2.667ms @ 48kHz —
// see audioPipeline.ts's `new AudioContext({ sampleRate: 48000 })`), NOT
// once per ~16ms poll like the setTimeout fallback. These frame counts
// are therefore ~6x the fallback's, so both paths gate on the same
// wall-clock timing.
this._gateOnFrames = 75; // ~200ms of silence before gating
this._gateOffFrames = 12; // ~32ms of speech before ungating
this._silentFrames = 0;
this._speechFrames = 0;
this._gated = false;
this._active = true;
this._startupFrames = 0;
this._startupGrace = 30; // ~500ms grace period
this._startupGrace = 188; // ~500ms grace period
this._frameCounter = 0; // for throttled RMS updates
this.port.onmessage = (event) => {
@@ -65,10 +70,10 @@ class VadProcessor extends AudioWorkletProcessor {
return true;
}
// Send RMS value to main thread every ~6 frames (~50ms at 128 samples/frame @ 48kHz)
// Send RMS value to main thread every ~19 frames (~50ms at 128 samples/frame @ 48kHz)
// This is used for the VAD indicator bar in the UI
this._frameCounter++;
if (this._frameCounter >= 6) {
if (this._frameCounter >= 19) {
this._frameCounter = 0;
this.port.postMessage({ type: "rms", value: rms });
}
+31 -1
View File
@@ -300,7 +300,17 @@ impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier {
/// *non-default* port keeps its brackets: "[::1]:8443" stays its own distinct
/// key, matching how a plain "host:8443" is never collapsed into "host".
pub(crate) fn cert_store_key(host: &str) -> String {
let stripped = host.strip_suffix(":443").unwrap_or(host);
// Only strip a trailing ":443" when what's left is unambiguously a host
// (no remaining colon) or a bracketed IPv6 literal (ends in `]`, as in
// "[::1]:443"). Without this guard, a BARE IPv6 literal whose final
// hextet is "443" — e.g. "fd00::443" — would have that hextet eaten as
// if it were a port, truncating the address to "fd00:" and pinning the
// same server under a different key than the ws/livekit proxies use for
// the bracketed form of the same address (OC-0215).
let stripped = match host.strip_suffix(":443") {
Some(rest) if !rest.contains(':') || rest.ends_with(']') => rest,
_ => host,
};
let unbracketed = stripped
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
@@ -431,6 +441,26 @@ mod tests {
assert_eq!(cert_store_key("[2001:db8::1]:8443"), "[2001:db8::1]:8443");
}
// OC-0215: a BARE (unbracketed) IPv6 literal whose final hextet happens to
// be "443" must NOT have that hextet eaten by the ":443" default-port
// strip — "fd00::443" is a whole address, not "fd00::" on port 443. The
// http proxy passes bare hosts verbatim (http_proxy::split_host_port has
// an explicit `!host.contains(':')` guard for exactly this reason), while
// the ws/livekit proxies see the bracketed form of the same address. All
// three MUST resolve to the same key or the same server's certificate is
// pinned (and re-confirmed by the user) under two different entries.
#[test]
fn cert_store_key_does_not_truncate_bare_ipv6_ending_in_443() {
assert_eq!(cert_store_key("fd00::443"), "fd00::443");
// Must agree with the bracketed forms the ws/livekit proxies derive
// for the very same server.
assert_eq!(cert_store_key("fd00::443"), cert_store_key("[fd00::443]"));
assert_eq!(
cert_store_key("fd00::443"),
cert_store_key("[fd00::443]:443")
);
}
// DNS names are case-insensitive, but a raw host string (a profile-entered
// host, or one taken verbatim from a wss:// URL) is not normalized before
// reaching here. Two call sites can derive the SAME host in different
@@ -745,6 +745,17 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
onPurgeChannel,
} = options;
const ac = new AbortController();
// renderChannels() rebuilds every row from scratch on every channels-store
// notification (unread count, active channel, role change, mute toggle,
// ...). Per-row listeners (context menu, drag handlers) must NOT be
// registered on the sidebar-lifetime `ac.signal`, which only aborts once,
// at destroy() -- addEventListener({ signal }) keeps a detached row alive
// via that signal's own retained "abort" listener list until it fires, so
// every re-render would otherwise leak one full set of detached rows
// (OC-0229). renderAc is aborted and replaced at the top of every
// renderChannels() call, so only the CURRENT render's rows stay reachable;
// header/root listeners registered once in mount() keep using `ac.signal`.
let renderAc: AbortController | null = null;
let root: HTMLDivElement | null = null;
let channelList: HTMLDivElement | null = null;
let serverNameEl: HTMLSpanElement | null = null;
@@ -778,6 +789,12 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
if (channelList === null) {
return;
}
// Abort the previous render's row-scoped listeners before the rows they
// belong to are detached below, so a stale row can never outlive the
// render that replaced it (OC-0229).
renderAc?.abort();
const currentRenderAc = new AbortController();
renderAc = currentRenderAc;
clearChildren(channelList);
voiceRowByUserId.clear();
@@ -803,7 +820,7 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
category,
channels,
state.activeChannelId,
ac.signal,
currentRenderAc.signal,
onVoiceJoin,
onVoiceLeave,
onCreateChannel,
@@ -937,10 +954,13 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
for (const [chId, users] of state.voiceUsers) {
structSig += `|${chId}`;
for (const [uid, u] of users) {
// Include the E2EE verification status so a verified↔unverified↔mismatch
// flip re-renders the badge (it lives outside voiceUsers, in peerVerifications).
// Include the E2EE verification status, safety number, and session
// fingerprint so a verified↔unverified↔mismatch flip *and* a
// same-status fingerprint/safety-number change (e.g. a reconnect that
// re-announces a fresh ephemeral key, OC-0208) both re-render the
// badge (it lives outside voiceUsers, in peerVerifications).
const verif = state.peerVerifications?.get(uid);
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}` : ""}`;
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 ?? ""}` : ""}`;
}
}
return structSig;
@@ -968,6 +988,8 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
// ac.abort() also releases this sidebar's hold on the shared document-level
// drag listeners (drag-reorder.ts tracks owners by signal).
ac.abort();
renderAc?.abort();
renderAc = null;
for (const unsub of unsubscribers) {
unsub();
}
@@ -126,6 +126,11 @@ const TYPING_THROTTLE_MS = 3_000;
const MAX_TEXTAREA_HEIGHT = 200;
const SEND_DEBOUNCE_MS = 200;
const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB matches server limit
// Server/ws/command.go rejects the whole chat_send frame (as a generic parse
// error, not an attachment-specific one) once len(Attachments) > 10 -- cap
// the queue client-side so we never upload an attachment doomed to be
// orphaned by a send that can never succeed.
const MAX_ATTACHMENTS = 10;
const ALLOWED_TYPES = [
"image/",
"video/",
@@ -556,6 +561,14 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
return;
}
// Cap the queue at the server's hard limit. Refusing here -- before the
// upload starts -- keeps the composer's state and the eventual send in
// sync with what the server will actually accept.
if (pendingAttachments.length >= MAX_ATTACHMENTS) {
showUploadError(`You can attach at most ${MAX_ATTACHMENTS} files to a message`);
return;
}
const tempId = `pending-${++previewCounter}`;
const isImage = file.type.startsWith("image/");
@@ -264,6 +264,12 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
let renderedStart = 0;
let renderedEnd = 0;
// scrollToMessage's highlight-flash: at most one outstanding flash at a
// time, so its cleanup timer never needs a per-call abort listener (which
// would accumulate one listener — and pin one row element — per jump).
let flashTimer = 0;
let flashEl: HTMLElement | null = null;
/**
* Unread count this channel carried when the visit that created this list
* began. Read once here, not per render: the badge is cleared by the visit
@@ -965,6 +971,11 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
clearTimeout(renderWindowResetTimer);
renderWindowResetTimer = 0;
}
if (flashTimer !== 0) {
clearTimeout(flashTimer);
flashTimer = 0;
flashEl = null;
}
unsubLoadingReset();
for (const unsub of unsubscribers) {
unsub();
@@ -1013,12 +1024,19 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
const localIdx = idx - renderedStart;
const el = contentContainer.children[localIdx] as HTMLElement | undefined;
if (el !== undefined) {
// A prior flash still pending (rapid repeat jumps) must not linger on
// its now-stale row, and must not leave its timer live once replaced.
if (flashTimer !== 0) {
clearTimeout(flashTimer);
flashEl?.classList.remove("highlight-flash");
}
el.classList.add("highlight-flash");
const timer = window.setTimeout(() => {
flashEl = el;
flashTimer = window.setTimeout(() => {
el.classList.remove("highlight-flash");
flashTimer = 0;
flashEl = null;
}, 1500);
// Unmounting mid-flash must not leave a timer pointing at a dead node.
ac.signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
}
}
@@ -26,6 +26,10 @@ export interface TileConfig {
export interface VideoGridComponent extends MountableComponent {
addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void;
/** Update an already-open tile's label in place (e.g. a mid-call rename).
* No-op if no tile is open for this id — callers don't need to know
* whether the tile exists. */
setLabel(userId: number, username: string): void;
removeStream(userId: number): void;
/** Remove every tile — used on a real voice leave so stale remote tiles
* from the previous session don't survive into the next join. */
@@ -401,6 +405,19 @@ export function createVideoGrid(): VideoGridComponent {
}
}
/** Update an already-open tile's label in place. No-op if the tile isn't
* open — used to keep a remote tile's name in sync with a mid-call
* rename without re-creating the tile (addStream is only called once per
* tile, from the LiveKit TrackSubscribed callback). */
function setLabel(userId: number, username: string): void {
const entry = cells.get(userId);
if (entry === undefined) return;
const label = entry.el.querySelector(".video-username");
if (label !== null) {
label.textContent = username;
}
}
function removeStream(userId: number): void {
const entry = cells.get(userId);
if (entry === undefined) return;
@@ -487,6 +504,7 @@ export function createVideoGrid(): VideoGridComponent {
mount,
destroy,
addStream,
setLabel,
removeStream,
clearStreams,
hasStreams,
@@ -44,6 +44,29 @@ export const MESSAGE_LINK_REGEX = /owncord:\/\/message\/\d+\/\d+/g;
export type { MentionInfo };
/**
* Strip trailing punctuation that is likely sentence-level, not part of the
* URL — e.g. the period after "https://example.com." in "Check this out.".
*
* Gives back one trailing ")" if it balances an unmatched "(" earlier in the
* URL, since `https://en.wikipedia.org/wiki/Rust_(programming_language)` is a
* real address, not prose wrapped in parens.
*
* This is the single source of truth for "what counts as part of the URL vs.
* surrounding prose" — every consumer of a raw URL_REGEX match (linkifying
* anchors, extracting URLs for the embed pipeline) must strip through this
* function so they agree on the same URL.
*/
export function stripUrlTrailingPunctuation(rawUrl: string): string {
let stripped = rawUrl.replace(/[.,;:!?)]+$/, "");
if (rawUrl.length > stripped.length && rawUrl[stripped.length] === ")") {
const opens = (stripped.match(/\(/g) ?? []).length;
const closes = (stripped.match(/\)/g) ?? []).length;
if (opens > closes) stripped = stripped + ")";
}
return stripped || rawUrl; // fallback if stripping emptied it
}
/** Quotes may contain blocks, but a quote inside a quote inside a quote is a
* fight the renderer does not need to have. */
const MAX_BLOCK_DEPTH = 2;
@@ -168,17 +191,9 @@ export function renderMentions(text: string, info?: MentionInfo): DocumentFragme
}
// Strip trailing punctuation that is likely sentence-level, not part of the URL
const rawUrl = match[0];
let stripped = rawUrl.replace(/[.,;:!?)]+$/, "");
// Give back one trailing ")" if it balances an unmatched "(" earlier in
// the URL — e.g. https://en.wikipedia.org/wiki/Rust_(programming_language)
// is a real address, not prose wrapped in parens.
if (rawUrl.length > stripped.length && rawUrl[stripped.length] === ")") {
const opens = (stripped.match(/\(/g) ?? []).length;
const closes = (stripped.match(/\)/g) ?? []).length;
if (opens > closes) stripped = stripped + ")";
}
const stripped = stripUrlTrailingPunctuation(rawUrl);
const trailing = rawUrl.slice(stripped.length);
const url = stripped || rawUrl; // fallback if stripping emptied it
const url = stripped;
if (isSafeUrl(url)) {
const link = createElement("a", {
class: "msg-link",
@@ -15,6 +15,7 @@ import {
CODE_BLOCK_REGEX,
INLINE_CODE_REGEX,
MASKED_LINK_REGEX,
stripUrlTrailingPunctuation,
URL_REGEX,
} from "./content-parser";
import { renderGenericLinkPreview } from "./embeds";
@@ -513,7 +514,10 @@ export function extractUrls(content: string): string[] {
.replace(INLINE_CODE_REGEX, "")
.replace(MASKED_LINK_REGEX, "");
const matches = withoutCodeBlocks.match(URL_REGEX);
return matches ?? [];
// Strip the same trailing sentence punctuation the linkifier strips (see
// stripUrlTrailingPunctuation), so the embed pipeline and the rendered
// anchor agree on exactly the same URL.
return (matches ?? []).map(stripUrlTrailingPunctuation);
}
/** Render URL embeds (YouTube players, generic link previews). */
@@ -193,7 +193,14 @@ const DEFAULT_IDENT = /[A-Za-z_$][A-Za-z0-9_$]*/y;
/** Canonical language id for a fence tag, or null when unknown. */
export function resolveLanguage(tag: string | null): string | null {
if (tag === null) return null;
return ALIASES[tag.toLowerCase()] ?? null;
const key = tag.toLowerCase();
// Object.hasOwn guards against inherited keys ("constructor", "toString"),
// which a bare index read would resolve to a prototype value. Once that
// guard holds the own value is a real string, but noUncheckedIndexedAccess
// still types the read as string | undefined, so narrow it explicitly.
if (!Object.hasOwn(ALIASES, key)) return null;
const canonical = ALIASES[key];
return canonical === undefined ? null : canonical;
}
/**
@@ -20,8 +20,16 @@ const TOGGLES: ReadonlyArray<ToggleItem> = [
label: "Reduce Motion",
desc: "Disable animations and transitions",
fallback: false,
sideEffect: (nowOn) => {
document.documentElement.classList.toggle("reduced-motion", nowOn);
// Do not write the `reduced-motion` class directly here: when "Sync with
// OS" is on, os-motion.ts owns that class via a live media-query
// listener, and writing it directly would silently fight that listener
// (OC-0232). savePref has already stored the new manual value by the
// time this runs, so re-invoking syncOsMotionListener lets whichever
// source is supposed to own the class re-derive it consistently: ON
// re-reads the OS media query (OS wins), OFF re-reads the just-saved
// manual pref — matching applyStoredAppearance's startup ordering.
sideEffect: () => {
syncOsMotionListener(loadPref<boolean>("syncOsMotion", false));
},
},
{
@@ -78,6 +78,7 @@ export interface LogsTabHandle {
export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal): LogsTabHandle {
let logListEl: HTMLDivElement | null = null;
let countEl: HTMLDivElement | null = null;
let logFilterLevel: LogLevel | "all" = readMigratedStringPref(
"logs_filter_level",
"all",
@@ -85,11 +86,18 @@ export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal):
);
let unsubLogListener: (() => void) | null = null;
// Single point of truth for both the list and the "N entries" counter above
// it, so every render path (filter change, Clear, Refresh, live entry)
// keeps them in sync — see OC-0230.
function renderLogEntries(): void {
const entries = getLogBuffer();
if (countEl !== null) {
countEl.textContent = `${entries.length} entries`;
}
if (logListEl === null) return;
clearChildren(logListEl);
const entries = getLogBuffer();
for (const entry of entries) {
if (logFilterLevel !== "all" && entry.level !== logFilterLevel) continue;
logListEl.appendChild(formatLogEntry(entry));
@@ -314,7 +322,7 @@ export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal):
section.appendChild(diagBtns);
// Log count
const countEl = createElement(
countEl = createElement(
"div",
{
style: "font-size: 12px; color: #888; margin: 12px 0 4px 0;",
@@ -338,7 +346,6 @@ export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal):
unsubLogListener = addLogListener(() => {
if (getActiveTab() === "Logs") {
renderLogEntries();
countEl.textContent = `${getLogBuffer().length} entries`;
}
});
@@ -349,6 +356,7 @@ export function createLogsTab(getActiveTab: () => TabName, signal: AbortSignal):
unsubLogListener?.();
unsubLogListener = null;
logListEl = null;
countEl = null;
}
return { build, cleanup };
@@ -424,6 +424,13 @@ export class AudioPipeline {
}
// Stop AudioWorklet
if (this.vadWorkletNode !== null) {
// Detach the handler first — the worklet's `process()` loop only
// observes `stop` on its next audio-thread callback, so it can still
// post one more {type:"gate"} message after this postMessage but
// before it does. Leaving onmessage live would let that late message
// re-gate the mic with no VAD left running to ever un-gate it again
// (OC-0231).
this.vadWorkletNode.port.onmessage = null;
// oxlint-disable-next-line require-post-message-target-origin -- MessagePort.postMessage, not Window.postMessage
this.vadWorkletNode.port.postMessage({ type: "stop" });
this.vadWorkletNode.disconnect();
+13
View File
@@ -108,6 +108,19 @@ export function startAutoIdle(options: AutoIdleOptions): AutoIdleController {
timer = null;
if (destroyed) return;
apply(true);
// Re-check: apply() invokes options.onStatusChange synchronously, and a
// caller reacting to that (e.g. tearing down the page) may call
// destroy() from inside it. `timer` is already null at this point, so
// destroy()'s clearTimeout would be a no-op — the re-check below is
// what actually stops a synchronous destroy from being undone.
if (destroyed) return;
// Keep watching even when this firing changed nothing (already idle,
// or dnd/invisible/manual-idle made it a no-op): a status change made
// through a surface that produces no DOM activity event — the OS tray's
// Status submenu calls saveUserStatus() directly — can make the status
// eligible again without ever calling arm() itself. Re-arming here is
// the one place that covers every such surface at once.
arm();
}, delayMs);
}
+94 -17
View File
@@ -31,6 +31,7 @@ import {
invalidateLoadedMessageWindows,
setChannelLoading,
setChannelLoadError,
isWindowDetached,
} from "@stores/messages.store";
import {
setMembers,
@@ -65,7 +66,12 @@ import {
updateDmParticipant,
} from "@stores/dm.store";
import type { DmChannel } from "@stores/dm.store";
import { setBlockedByMe, setUserBlockedByThem, clearBlockedByThem } from "@stores/blocks.store";
import {
blocksStore,
setBlockedByMe,
setUserBlockedByThem,
clearBlockedByThem,
} from "@stores/blocks.store";
import { setCustomEmoji } from "@stores/emoji.store";
import type { DmChannelPayload } from "./types";
import { isTextLikeChannel } from "./types";
@@ -267,6 +273,16 @@ export function wireDispatcher(
unsubs.push(
ws.on(S.READY, (payload) => {
// OC-0201: snapshot the current voice channel's peer roster BEFORE the
// wholesale replace below, so the reconciliation branch further down
// can tell who left while the socket was down. Must run before
// setVoiceStates() overwrites voiceUsers with the fresh payload.
const prevVoiceChannelId = voiceStore.getState().currentChannelId;
const prevVoicePeerIds =
prevVoiceChannelId !== null
? new Set(voiceStore.getState().voiceUsers.get(prevVoiceChannelId)?.keys() ?? [])
: new Set<number>();
setChannels(payload.channels);
setRoles(payload.roles ?? []);
setMembers(payload.members);
@@ -303,6 +319,31 @@ export function wireDispatcher(
selfVoiceState.server_muted === true,
selfVoiceState.server_deafened === true,
);
// OC-0201: same gap, for E2EE. A full resync never replays the
// voice_leave for anyone who departed our voice channel during the
// outage — handleParticipantLeft (the only path that prunes a
// departed peer's key, rotates for membership forward secrecy, and
// re-runs the lowest-uid key-holder election) is otherwise only ever
// driven by a live voice_leave frame. Without this, a departed peer
// keeps a working room key indefinitely, and a client the server
// just elected key holder on reconnect (Server/ws hub.go
// registerNow -> updateKeyHolder) never self-elects. Only reconcile
// when the resync's self voice state is for the SAME channel the
// snapshot above was taken from — a channel change is out of scope
// here and comparing rosters across two different channels would
// misfire.
if (prevVoiceChannelId === selfVoiceState.channel_id) {
const currentVoicePeerIds = new Set(
payload.voice_states
.filter((vs) => vs.channel_id === selfVoiceState.channel_id)
.map((vs) => vs.user_id),
);
for (const uid of prevVoicePeerIds) {
if (uid === currentUserId || currentVoicePeerIds.has(uid)) continue;
void livekitSession().then(({ handleParticipantLeft }) => handleParticipantLeft(uid));
}
}
}
// F3: publish our long-term identity public key so peers can pin+verify
@@ -463,9 +504,17 @@ export function wireDispatcher(
// clear it and re-fetch our own outgoing blocks authoritatively.
clearBlockedByThem();
if (api !== undefined) {
// OC-0218: snapshot the revision blocksStore was at right before
// issuing this fetch. If the user blocks/unblocks someone (via
// SidebarMemberSection's onToggleBlock -> setUserBlockedByMe) while
// this GET is in flight, that per-user delta bumps the revision;
// setBlockedByMe then sees the mismatch and skips applying this
// reply instead of clobbering the fresher local truth with a stale
// full-set snapshot.
const blockedByMeRevAtFetch = blocksStore.getState().blockedByMeRev ?? 0;
api
.listBlocks()
.then((r) => setBlockedByMe(r.blocked_user_ids))
.then((r) => setBlockedByMe(r.blocked_user_ids, blockedByMeRevAtFetch))
.catch((err) => log.warn("Failed to load block list", { error: String(err) }));
}
@@ -566,30 +615,46 @@ export function wireDispatcher(
const currentUserId = authStore.getState().user?.id ?? null;
const isOwnMessage = currentUserId !== null && payload.user.id === currentUserId;
// Increment channel-level unread for non-active, non-own-message channels.
// Replayed frames increment unread counts like live ones — the burst
// is exactly the messages missed while away (a full-ready resume sends
// no burst at all; ready's unread_count values are authoritative
// there). DM channel IDs are not in channelsStore (they use dmStore),
// so incrementUnread is a no-op for DMs, but the own-message guard is
// applied here for defence-in-depth.
// Increment channel-level unread for non-active, non-own-message
// channels — OR for the active channel when its loaded window is
// detached from the live tail (OC-0204). "Active" normally means "the
// user is watching the live tail", which is why it is otherwise
// excluded here, but a jump to an old permalink/reply/search hit can
// leave the active channel showing a detached around-window
// (messages.store's detachedChannels) — addMessage already refuses to
// append a live broadcast onto that window, so without this a message
// (an @mention included) that arrives while the user reads
// back-history leaves no row AND no badge, with nothing to tell them
// it ever arrived. Replayed frames increment unread counts like live
// ones — the burst is exactly the messages missed while away (a
// full-ready resume sends no burst at all; ready's unread_count values
// are authoritative there). DM channel IDs are not in channelsStore
// (they use dmStore), so incrementUnread is a no-op for DMs, but the
// own-message guard is applied here for defence-in-depth.
const isMention = highlightsCurrentUser(payload.content, {
mentions: payload.mentions,
mentionsEveryone: payload.mentions_everyone,
});
const isDetached = isWindowDetached(payload.channel_id);
if (payload.channel_id !== activeId && !isOwnMessage) {
incrementUnread(payload.channel_id);
if ((payload.channel_id !== activeId || isDetached) && !isOwnMessage) {
// incrementUnread/incrementMention skip the active channel by
// default — evenIfActive (isDetached here) is a no-op for a
// genuinely non-active channel, since their internal guard only
// fires when channelId IS the active one.
incrementUnread(payload.channel_id, isDetached);
// A mention is an unread too — the mention badge just outranks it.
if (isMention) {
incrementMention(payload.channel_id);
incrementMention(payload.channel_id, isDetached);
}
}
// Update DM store last message if this message belongs to a DM channel.
// Skip unread increment for own messages and the currently focused DM.
// Skip unread increment for own messages and the currently focused DM
// — unless that DM's window is detached from the live tail (OC-0204),
// the same exception the channel-level increment above makes.
if (isDm) {
const isDmActive = payload.channel_id === activeId;
const isDmActive = payload.channel_id === activeId && !isDetached;
if (isOwnMessage || isDmActive) {
// Update last message preview but don't increment unread count.
updateDmLastMessagePreview(
@@ -1111,10 +1176,22 @@ export function wireDispatcher(
// that voice_leave's channel no longer matches the already-updated
// currentChannelId, so it must not tear down the NEW channel's
// optimistic state either), so voiceStatus is still "joining" when
// this error lands and the guard clears it here instead. An
// already-established session is never in "joining", so this never
// touches a live voice call.
// this error lands and the guard clears it here instead. A plain
// store rollback is safe for an already-established session (never
// "joining") and for a first-time join refusal (no prior session to
// tear down) — but a channel *switch* refused at precheck (RATE_LIMITED,
// FORBIDDEN, NOT_FOUND, archived-channel BAD_REQUEST) never reaches
// voiceJoinLeaveCurrent server-side, so no voice_leave is broadcast and
// the OLD channel's LiveKit room is still connected (mic still
// published) while the store already points at the NEW channel
// (OC-0193). isVoiceConnected() distinguishes that live-session case
// from the first-time-join refusal; tearing it down here also sends
// voice_leave so the server/SFU state for the OLD channel matches the
// now-cleared store.
if (voiceStore.getState().voiceStatus === "joining") {
void livekitSession().then(({ isVoiceConnected, leaveVoice }) => {
if (isVoiceConnected()) leaveVoice(true);
});
leaveVoiceChannel();
}
// Voice capacity refusals. The server owns the limits (voice_max_users /
+69 -23
View File
@@ -94,6 +94,15 @@ export class E2EEManager {
publicKeyBase64: string;
signatureBase64?: string;
}> = [];
/** Announce that verifyPeerAnnounce rejected as a TOFU pin mismatch, keyed
* by userId — buffered so a subsequent successful rePinPeerIdentity can
* replay it instead of leaving the recovery a no-op for the live call
* (OC-0212): a mid-call peer never re-announces on its own, so nothing
* else would re-run verification against the freshly-stored pin. At most
* one entry per peer; a later mismatch (or a later legitimate announce)
* simply overwrites the previous one. Cleared in clearState(). */
private _blockedAnnounces: Map<number, { publicKeyBase64: string; signatureBase64?: string }> =
new Map();
/** Periodic key rotation timer — fires every KEY_ROTATION_INTERVAL_MS when key holder. */
private _keyRotationTimer: ReturnType<typeof setTimeout> | null = null;
/** Interval between periodic key rotations (5 minutes). */
@@ -535,6 +544,11 @@ export class E2EEManager {
// Pinned peer whose delivered key is absent or differs from the pin —
// possible server MITM. Block until the user re-pins.
if (pin !== null && publishedIdentity !== pin) {
// Buffer this announce (OC-0212) so a successful rePinPeerIdentity can
// replay it: a mid-call peer never re-announces on its own, so without
// this, re-pinning writes a new pin that nothing ever verifies the
// peer's key against, leaving them un-keyed for the rest of the call.
this._blockedAnnounces.set(userId, { publicKeyBase64, signatureBase64 });
this.setPeerVerificationIfCurrent(myGeneration, {
userId,
status: "mismatch",
@@ -671,7 +685,23 @@ export class E2EEManager {
});
return false;
}
clearPeerVerification(userId);
// Replay the announce verifyPeerAnnounce buffered when it blocked this
// peer as a mismatch (OC-0212). Without this, the pin write above is a
// no-op for the live call: nothing else re-runs the peer's announce, so
// they never (re-)enter _peerPublicKeys — staying out of every offer and
// rotation for the rest of the call — and clearPeerVerification below
// would erase the badge entirely rather than showing the real (now
// hopefully "verified") outcome. handleAnnounce re-verifies against the
// pin just stored above and writes the real status itself, so it stands
// in for clearPeerVerification when a replay is available.
const pending = this._blockedAnnounces.get(userId);
if (pending) {
this._blockedAnnounces.delete(userId);
log.info("E2EE: replaying blocked announce after re-pin (TOFU recovery)", { userId });
await this.handleAnnounce(userId, pending.publicKeyBase64, pending.signatureBase64);
} else {
clearPeerVerification(userId);
}
log.info("E2EE: re-pinned peer identity key (TOFU recovery)", { userId });
return true;
}
@@ -739,6 +769,20 @@ export class E2EEManager {
// can be detected before this continuation writes into a session a newer
// (or no) attempt now owns (finding B3-7).
const myGeneration = this._sessionGeneration;
// Reject a replay of a key we've already retired for this peer BEFORE
// verifyPeerAnnounce runs (OC-0209). verifyPeerAnnounce writes the peer's
// displayed verification (status + sessionFingerprint, computed from
// THIS announce's key) on every branch it can take, including its
// success branches — so if the replay guard ran only after verification
// (as it used to, further below), a replayed announce would overwrite
// the peer's badge with the retired key's fingerprint/status before
// being rejected, even though _peerPublicKeys itself was never touched.
// This check is synchronous (no await), so it introduces no new window
// for a session to be superseded before it runs.
if (this.isRetiredPeerKey(userId, publicKeyBase64)) {
log.error("E2EE: rejecting replayed peer key announce (previously retired)", { userId });
return;
}
try {
// ── F3 TOFU verification gate ──────────────────────────────────────
// Resolve the peer's identity key and verify the announce signature
@@ -768,29 +812,14 @@ export class E2EEManager {
isDuplicate = true;
log.debug("E2EE: duplicate announce — will re-send offer if key holder", { userId });
} else {
// Reject a replay of a key we've already retired for this peer. The
// signed announce message carries no channel/epoch/nonce (F3), so an
// old, validly-signed announce replays cleanly — without this check a
// malicious relay could re-emit a recorded announce and swap the live
// key back to one nobody holds anymore, silently blackholing the peer
// (OC-0011). A genuine peer never reuses an ephemeral key across
// sessions (freshly generated every join), so this never rejects a
// legitimate re-announce.
if (this.isRetiredPeerKey(userId, publicKeyBase64)) {
log.error("E2EE: rejecting replayed peer key announce (previously retired)", {
userId,
});
return;
}
// The replay-of-a-retired-key check now runs up front (OC-0209),
// before verifyPeerAnnounce — see the comment there (was
// previously duplicated in both branches here).
this.retirePeerKey(userId, existingB64);
peerKey = await importPublicKey(publicKeyBase64);
log.warn("E2EE: peer public key changed (reconnect?)", { userId });
}
} else {
if (this.isRetiredPeerKey(userId, publicKeyBase64)) {
log.error("E2EE: rejecting replayed peer key announce (previously retired)", { userId });
return;
}
peerKey = await importPublicKey(publicKeyBase64);
}
// Re-check after the export/import awaits above: a clearState()+rejoin
@@ -1200,6 +1229,11 @@ export class E2EEManager {
this._peerPublicKeys.delete(userId);
this._peerOfferEpochs.delete(userId);
clearPeerVerification(userId);
const channelId = this._channelId ?? this.deps.getCurrentChannelId();
const state = voiceStore.getState();
const channelUsers = channelId ? state.voiceUsers.get(channelId) : undefined;
// Retire the departing peer's key (OC-0020): _retiredPeerKeys is the only
// defense against replay of a validly-signed announce (the signed
// message carries no channel/epoch/nonce, F3) and handleAnnounceInner
@@ -1210,15 +1244,26 @@ export class E2EEManager {
// whose private half no longer exists (blackholing them). A genuine
// rejoin always mints a fresh ECDH pair (setupKeyExchange,
// reannounceForReconnect), so this never rejects a legitimate re-announce.
if (departingKey) {
//
// BUT: voice_leave travels through the buffered hub broadcast queue while
// voice_e2ee_announce is published straight into the recipient's send
// queue from the sender's read-pump (Server/ws/hub_broadcast.go documents
// this as a reordering hazard) — a peer's rejoin announce can overtake
// the stale voice_leave for the join instance it superseded (OC-0213).
// If the local roster (voice_state, kept current by the server) still
// lists this peer as present in the channel, this IS that stale case:
// retiring their (in that case, still-live) key would have every later,
// genuine re-announce of it rejected as a replay, permanently stranding
// a peer who never actually left. Skip retirement in that case — the key
// is still removed from _peerPublicKeys above (and, below, this event
// still correctly excludes them from any resulting rotation) so nothing
// regresses for a genuine departure.
if (departingKey && !channelUsers?.has(userId)) {
this.retirePeerKey(userId, await exportPublicKey(departingKey));
}
const channelId = this._channelId ?? this.deps.getCurrentChannelId();
if (!channelId) return;
const state = voiceStore.getState();
const channelUsers = state.voiceUsers.get(channelId);
const myUserId = authStore.getState().user?.id ?? 0;
// Elect key holder: lowest user_id among remaining participants. The
@@ -1423,6 +1468,7 @@ export class E2EEManager {
this._rotationPending = false;
this._e2eeEpoch = 0;
this._pendingAnnounces.length = 0;
this._blockedAnnounces.clear();
// The server's offer rate limit is scoped per (sender, channel) — a
// fresh channel gets a fresh bucket server-side, so stale timestamps
// from the old channel must not throttle the new one.
+6
View File
@@ -74,6 +74,12 @@ export function resolveMentionUserId(token: string, info?: MentionInfo): number
const member = members.get(id);
if (member !== undefined && matches(member.username)) return id;
}
// The server is authoritative once it has spoken: a token it did not list
// must not be resolved locally either, or the row-level gate (which trusts
// info.mentions outright) and this token-level pill disagree on the same
// message. Only fall back to the member-list/self scan when the server
// sent no list at all (predates mentions, or a purely local render).
if (info?.mentions !== undefined) return null;
for (const member of members.values()) {
if (matches(member.username)) return member.id;
}
+27 -4
View File
@@ -9,9 +9,12 @@ import { loadUserStatus } from "./userStatus";
import { authStore } from "@stores/auth.store";
import { channelsStore } from "@stores/channels.store";
import { dmStore, dmDisplayName } from "@stores/dm.store";
import { isWindowDetached } from "@stores/messages.store";
import type { ChatMessagePayload } from "./types";
import { mentionsCurrentUser } from "./mentions";
import { createLogger } from "./logger";
import { resolveAuthor } from "@components/message-list/formatting";
import { resolveDisplayName } from "@lib/avatar";
const log = createLogger("notifications");
@@ -51,9 +54,22 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
// Don't notify for own messages
if (currentUser !== null && payload.user.id === currentUser.id) return;
// Don't notify if the window is focused AND the message is in the active channel
// Don't notify if the window is focused AND the message is in the active
// channel — UNLESS that channel is showing a detached around-window
// (OC-0204). "Active" only means this is the channel on screen; a jump to
// an old permalink/reply/search hit can leave it detached from the live
// tail (messages.store's detachedChannels), in which case the user is
// reading back-history and cannot see the new message at all — addMessage
// silently refuses to append it. Without this check that combination
// suppresses the one thing that would have told the user anything arrived.
const activeChannelId = channelsStore.getState().activeChannelId;
if (isWindowFocused() && payload.channel_id === activeChannelId) return;
if (
isWindowFocused() &&
payload.channel_id === activeChannelId &&
!isWindowDetached(payload.channel_id)
) {
return;
}
const mentionInfo = {
mentions: payload.mentions,
@@ -87,6 +103,13 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
const { name: channelName, isDm } = resolveNotificationChannel(payload.channel_id);
const channelLabel = isDm ? channelName : `#${channelName}`;
// The name to show for the author, resolved the same way the message list
// resolves it (resolveAuthor prefers the live membersStore nickname over
// whatever was frozen into the payload; resolveDisplayName falls back to
// the username when no nickname is set). Without this the notification
// names the sender differently from the message row it points at.
const authorName = resolveDisplayName(resolveAuthor(payload.user));
// oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability
function sanitizeNotif(s: string, maxLen: number): string {
// eslint-disable-next-line no-control-regex -- intentional: strip control chars from user-provided strings
@@ -96,8 +119,8 @@ export function notifyIncomingMessage(payload: ChatMessagePayload): void {
const title = sanitizeNotif(
mentioned
? `${payload.user.username} mentioned you in ${channelLabel}`
: `${payload.user.username} in ${channelLabel}`,
? `${authorName} mentioned you in ${channelLabel}`
: `${authorName} in ${channelLabel}`,
80,
);
const body = sanitizeNotif(payload.content, 100);
+66 -12
View File
@@ -25,6 +25,7 @@ import { startAutoIdle, type AutoIdleController } from "@lib/autoIdle";
import { channelsStore, getActiveChannel } from "@stores/channels.store";
import { dmStore, dmDisplayName } from "@stores/dm.store";
import { voiceStore } from "@stores/voice.store";
import { membersStore, memberDisplayName } from "@stores/members.store";
import { clearCustomEmoji } from "@stores/emoji.store";
import {
cleanupAll as voiceCleanupAll,
@@ -621,12 +622,20 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
// The ringer hanging up before anyone answered: their voice_leave is the
// only signal there is that the call is over, because there is no call
// record to close. Ringing for a room with nobody in it is worse than a
// missed call, so a leave stops the ring for that channel.
// missed call, so a leave stops the ring for that channel — but only
// when the ringer leaving actually emptied it. A group DM can still hold
// other callees who already accepted (voiceStore.voiceUsers answers
// that), and the ringer hanging up must not silence a call that is
// still live for them (OC-0235).
unsubscribers.push(
ws.on("voice_leave", (payload) => {
const ringing = ringCtrl?.current();
if (ringing === null || ringing === undefined) return;
if (payload.user_id === ringing.fromUserId) {
if (payload.user_id !== ringing.fromUserId) return;
const roster = voiceStore.getState().voiceUsers.get(payload.channel_id);
const othersStillIn =
roster !== undefined && [...roster.keys()].some((id) => id !== payload.user_id);
if (!othersStillIn) {
ringCtrl?.cancel(payload.channel_id);
}
}),
@@ -673,20 +682,34 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
// Wire voice error callback to toast
setVoiceOnError((msg) => showToast(msg, "error"));
// The label shown on a remote video tile: memberDisplayName when known —
// the same identity a rename shows everywhere else (ChannelSidebar's
// voice roster, message rows, the member list) — falling back to the
// voice roster's (possibly frozen) username, then a placeholder. Single
// writer so tile creation (setOnRemoteVideo below) and tile relabeling
// on a mid-call rename (the voiceStore subscriber below) cannot disagree
// (OC-0227).
function remoteTileLabel(userId: number, isScreenshare: boolean): string {
const voice = voiceStore.getState();
const channelId = voice.currentChannelId;
const channelUsers = channelId !== null ? voice.voiceUsers.get(channelId) : undefined;
const voiceUser = channelUsers?.get(userId);
const member = membersStore.getState().members.get(userId);
const name = (member !== undefined ? memberDisplayName(member) : "") || voiceUser?.username;
if (name === undefined || name === "") {
return isScreenshare ? `User ${userId} (Screen)` : `User ${userId}`;
}
return isScreenshare ? `${name} (Screen)` : name;
}
// Wire remote video callbacks to video grid
setOnRemoteVideo((userId, stream, isScreenshare) => {
if (videoGrid === null) return;
const voice = voiceStore.getState();
const channelId = voice.currentChannelId;
if (channelId === null) return;
const channelUsers = voice.voiceUsers.get(channelId);
const user = channelUsers?.get(userId);
const tileId = isScreenshare ? userId + SCREENSHARE_TILE_ID_OFFSET : userId;
const username = isScreenshare
? user?.username
? `${user.username} (Screen)`
: `User ${userId} (Screen)`
: (user?.username ?? `User ${userId}`);
const username = remoteTileLabel(userId, isScreenshare);
videoGrid.addStream(tileId, username, stream, {
isSelf: false,
audioUserId: userId,
@@ -701,20 +724,51 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
});
unsubscribers.push(() => clearOnRemoteVideo());
// Subscribe to voice store for camera/screenshare state changes only (not speaking ticks)
// Subscribe to voice store for camera/screenshare state changes, voice
// channel switches, and remote-tile identity changes (not speaking ticks)
let prevVideoSignature = "";
const prevTileLabels = new Map<number, string>();
unsubscribers.push(
voiceStore.subscribe((state) => {
try {
// Build a lightweight signature of video-relevant state (camera + screenshare)
let sig = (state.localCamera ? "c" : "") + (state.localScreenshare ? "s" : "");
const channelId = state.currentChannelId;
// Seed the signature with the channel id so ANY voice-channel
// switch changes it, even one where the camera/screenshare flags
// happen to be identical on both sides (e.g. both channels empty).
// Without this, VideoModeController.checkVideoMode() — the only
// writer of its own lastChannelId — never runs for that switch,
// so lastChannelId is still the old channel the next time it runs
// (e.g. right after setOnRemoteVideo adds a fresh remote tile),
// and it clears the grid it was just given (OC-0207).
let sig =
`${String(channelId)}|` +
(state.localCamera ? "c" : "") +
(state.localScreenshare ? "s" : "");
if (channelId !== null) {
const users = state.voiceUsers.get(channelId);
if (users) {
for (const [uid, u] of users) {
if (u.camera) sig += `:c${uid}`;
if (u.screenshare) sig += `:s${uid}`;
// Relabel an already-open remote tile whose display name
// changed (mid-call rename) — addStream only runs once per
// tile, so nothing else keeps its label in sync (OC-0227).
// setLabel() no-ops for a tile that isn't open yet.
if (u.camera) {
const label = remoteTileLabel(uid, false);
if (prevTileLabels.get(uid) !== label) {
prevTileLabels.set(uid, label);
videoGrid?.setLabel(uid, label);
}
}
if (u.screenshare) {
const tileId = uid + SCREENSHARE_TILE_ID_OFFSET;
const label = remoteTileLabel(uid, true);
if (prevTileLabels.get(tileId) !== label) {
prevTileLabels.set(tileId, label);
videoGrid?.setLabel(tileId, label);
}
}
}
}
}
@@ -223,6 +223,13 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
// onJumpToPresent — reattach clears "loaded" so the tail is refetched.
if (isWindowDetached(channelId)) {
reattachToPresent(channelId);
// OC-0204: while detached, this (already-active) channel could have
// picked up an unread/mention badge for messages that arrived below
// the gap (dispatcher.ts's evenIfActive path) — nothing else clears
// it, since incrementUnread's usual "active channel" skip is exactly
// what a detached window opts out of. Jumping to present is reading
// it, so mark it read the same way leaving a channel does.
markChannelRead(channelId);
if (channelAbort !== null) {
void msgCtrl.loadMessages(channelId, channelAbort.signal);
}
@@ -288,6 +295,11 @@ export function createChannelController(opts: ChannelControllerOptions): Channel
// Dropping the detached flag also clears "loaded", so loadMessages
// refetches the live tail instead of short-circuiting.
reattachToPresent(channelId);
// OC-0204: see performSend's identical call above — a detached
// active channel's badge (from dispatcher.ts's evenIfActive path)
// must be cleared here too, or it lingers after the user has jumped
// back to present and is looking straight at the live tail.
markChannelRead(channelId);
if (channelAbort !== null) {
void msgCtrl.loadMessages(channelId, channelAbort.signal);
}
@@ -4,6 +4,7 @@
*/
import { voiceStore } from "@stores/voice.store";
import { membersStore, memberDisplayName } from "@stores/members.store";
import { getLocalCameraStream, getLocalScreenshareStream } from "@lib/livekitSession";
import { SCREENSHARE_TILE_ID_OFFSET } from "@lib/constants";
import type { VideoGridComponent } from "@components/VideoGrid";
@@ -170,17 +171,21 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid
// Manage local self-view tile — only add once, skip if already showing
const currentUserId = getCurrentUserId();
// Prefer the member's display name (same identity every other surface
// shows for a rename — see ChannelSidebar.ts's voice roster) over the
// frozen voice-roster username (OC-0227).
const member = membersStore.getState().members.get(currentUserId);
const me = channelUsers.get(currentUserId);
const myName = (member !== undefined ? memberDisplayName(member) : "") || me?.username;
if (voice.localCamera) {
if (!localTileAdded) {
const localStream = getLocalCameraStream();
if (localStream !== null) {
const me = channelUsers.get(currentUserId);
videoGrid.addStream(
currentUserId,
me?.username ? `${me.username} (You)` : "You",
localStream,
{ isSelf: true, audioUserId: currentUserId, isScreenshare: false },
);
videoGrid.addStream(currentUserId, myName ? `${myName} (You)` : "You", localStream, {
isSelf: true,
audioUserId: currentUserId,
isScreenshare: false,
});
localTileAdded = true;
}
}
@@ -195,10 +200,9 @@ export function createVideoModeController(opts: VideoModeControllerOptions): Vid
if (!localScreenshareTileAdded) {
const localStream = getLocalScreenshareStream();
if (localStream !== null) {
const me = channelUsers.get(currentUserId);
videoGrid.addStream(
screenshareUserId,
me?.username ? `${me.username} (Screen)` : "Your Screen",
myName ? `${myName} (Screen)` : "Your Screen",
localStream,
{ isSelf: true, audioUserId: currentUserId, isScreenshare: true },
);
@@ -77,7 +77,10 @@ export function createVoiceWidgetCallbacks(
if (state.localMuted) {
voiceSessionSetMuted(false);
ws.send({ type: "voice_mute", payload: { muted: false } });
if (state.localDeafened) {
// A moderator-imposed deafen is not ours to lift; the server refuses
// the undeafen, so don't spend the round-trip (same guard as
// onDeafenToggle's localServerMuted check below).
if (state.localDeafened && state.localServerDeafened !== true) {
voiceSessionSetDeafened(false);
ws.send({ type: "voice_deafen", payload: { deafened: false } });
}
+8 -1
View File
@@ -9,6 +9,7 @@ import { resetVoiceStore, voiceStore } from "@stores/voice.store";
import { resetMessagesStore } from "@stores/messages.store";
import { resetChannelsStore } from "@stores/channels.store";
import { resetBlocksStore } from "@stores/blocks.store";
import { setSidebarMode } from "@stores/ui.store";
import { cleanupNotificationAudio } from "@lib/notifications";
import { clearNsfwAcknowledgements } from "@lib/nsfw-gate";
import { createLogger } from "@lib/logger";
@@ -76,7 +77,12 @@ export function setAuth(token: string, user: UserWithRole, serverName: string, m
* Also clears blocksStore: block state is keyed by user id, which (like
* channel/message ids) is only unique per-server — otherwise a previous
* server's blocked-user ids would gate DM composers on the next server
* until the next successful GET /blocks refetch. */
* until the next successful GET /blocks refetch. Also resets uiStore's
* sidebarMode (and, via setSidebarMode, activeDmUserId): unlike every other
* domain store, nothing in the `ready` payload restates sidebarMode, so a
* "dms" mode left over from the previous session would otherwise survive
* logout as module-global state and mount the DM sidebar (with the old
* server's DM peer id) on whatever server is signed into next. */
export function clearAuth(reason: LogoutReason = "user"): void {
// livekitSession (and the ~1.3 MB livekit-client SDK behind it) is loaded
// lazily so it stays out of the startup path. Only import it when there is
@@ -97,6 +103,7 @@ export function clearAuth(reason: LogoutReason = "user"): void {
resetMessagesStore();
resetChannelsStore();
resetBlocksStore();
setSidebarMode("channels");
// NSFW acknowledgements are per-viewer consent, not per-device: without this
// the next account signed into the same server inherits the previous user's
// acks and the age gate silently never appears for them. Host-scoping the
+29 -4
View File
@@ -21,18 +21,43 @@ export const BLOCKED_BY_THEM_REASON = "You can't message this user right now.";
export interface BlocksState {
readonly blockedByMe: ReadonlySet<number>;
readonly blockedByThem: ReadonlySet<number>;
/**
* Bumped by every accepted setUserBlockedByMe delta (OC-0218). Optional —
* absent/undefined reads as revision 0 — so state literals that predate
* this field (tests, a full setState replace) do not need updating.
*
* Lets a ready-time GET /blocks snapshot the revision it observed just
* before issuing the request and pass it back to setBlockedByMe: if a
* setUserBlockedByMe delta landed (bumping the revision) while that fetch
* was in flight, the fetch's reply is answering a question that is no
* longer current and must not clobber the fresher local truth.
*/
readonly blockedByMeRev?: number;
}
const INITIAL: BlocksState = {
blockedByMe: new Set(),
blockedByThem: new Set(),
blockedByMeRev: 0,
};
export const blocksStore = createStore<BlocksState>(INITIAL);
/** Replace the blocked-by-me set (from GET /blocks). */
export function setBlockedByMe(userIds: readonly number[]): void {
blocksStore.setState((prev) => ({ ...prev, blockedByMe: new Set(userIds) }));
/**
* Replace the blocked-by-me set (from GET /blocks).
*
* `rev`, when given, must match the store's current blockedByMeRev — the
* revision the caller observed right before starting the fetch this reply
* answers (OC-0218). A mismatch means a fresher setUserBlockedByMe delta
* landed after the fetch was issued, so this reply is stale and is skipped
* rather than reverting that delta. Omit `rev` to always apply (existing
* direct callers, tests).
*/
export function setBlockedByMe(userIds: readonly number[], rev?: number): void {
blocksStore.setState((prev) => {
if (rev !== undefined && rev !== (prev.blockedByMeRev ?? 0)) return prev;
return { ...prev, blockedByMe: new Set(userIds) };
});
}
/** Mark (or unmark) a user as blocked by the local user (after PUT/DELETE /blocks). */
@@ -42,7 +67,7 @@ export function setUserBlockedByMe(userId: number, blocked: boolean): void {
const next = new Set(prev.blockedByMe);
if (blocked) next.add(userId);
else next.delete(userId);
return { ...prev, blockedByMe: next };
return { ...prev, blockedByMe: next, blockedByMeRev: (prev.blockedByMeRev ?? 0) + 1 };
});
}
@@ -333,10 +333,19 @@ export function getChannelsByCategory(): Map<string | null, Channel[]> {
});
}
/** Increment unread count for a channel, unless it is the active channel. */
export function incrementUnread(channelId: number): void {
/**
* Increment unread count for a channel, unless it is the active channel.
*
* `evenIfActive` (OC-0204) opts out of that skip: "active" normally means
* "the user is watching the live tail" — the reason a badge would be
* redundant there — but the active channel's loaded window can be detached
* from the live tail (a jump to an old permalink/reply/search hit), in which
* case the message is genuinely unseen and must still count. Callers own
* deciding when that applies; this still always skips an unknown channel id.
*/
export function incrementUnread(channelId: number, evenIfActive = false): void {
channelsStore.setState((prev) => {
if (prev.activeChannelId === channelId) {
if (prev.activeChannelId === channelId && !evenIfActive) {
return prev;
}
const existing = prev.channels.get(channelId);
@@ -357,10 +366,12 @@ export function incrementUnread(channelId: number): void {
* Increment the mention count for a channel, unless it is the active channel.
* Callers also call incrementUnread — a mention is always an unread too, and
* the two counters are kept independent so the badge can outrank.
*
* `evenIfActive` mirrors incrementUnread's escape hatch — see its doc for why.
*/
export function incrementMention(channelId: number): void {
export function incrementMention(channelId: number, evenIfActive = false): void {
channelsStore.setState((prev) => {
if (prev.activeChannelId === channelId) {
if (prev.activeChannelId === channelId && !evenIfActive) {
return prev;
}
const existing = prev.channels.get(channelId);
+11 -2
View File
@@ -139,7 +139,10 @@ export function updateDmLastMessage(
lastMessageId: messageId,
lastMessage: content,
lastMessageAt: timestamp,
unreadCount: updated.unreadCount + 1,
unreadCount:
updated.lastMessageId !== null && messageId <= updated.lastMessageId
? updated.unreadCount
: updated.unreadCount + 1,
},
...rest,
],
@@ -194,7 +197,13 @@ export function clearDmUnread(channelId: number): void {
export function dmDisplayName(dm: DmChannel): string {
if (dm.name !== "") return dm.name;
const names = dm.participants.map((p) => (p.displayName ?? "") || p.username);
if (names.length === 0) return dm.recipient.username;
if (names.length === 0) {
return dm.recipient.username !== ""
? dm.recipient.username
: dm.isGroup
? "Empty group"
: "Unknown user";
}
if (!dm.isGroup) return names[0]!;
if (names.length <= 3) return names.join(", ");
return `${names.slice(0, 3).join(", ")} and ${names.length - 3} more`;
+13 -2
View File
@@ -688,10 +688,21 @@ export function buildTauriMockScript(opts: {
${
opts.identityPinError === true
? `throw new Error("keyring unavailable (mock)");`
: `return ${JSON.stringify(opts.identityPins ?? {})}[String(args?.userId)] ?? null;`
: `window.__mockIdentityPins ??= ${JSON.stringify(opts.identityPins ?? {})};
return window.__mockIdentityPins[String(args?.userId)] ?? null;`
}
}
if (cmd === "store_identity_pin") return null;
// A pin write must be visible to the next read, exactly as the real
// keyring is: re-pinning a mismatched peer replays the announce that
// was blocked and re-verifies it against the pin just stored
// (OC-0212). A no-op store would serve the stale pin straight back,
// so the replay would re-fail and the mock would report a permanent
// mismatch the real keyring never produces.
if (cmd === "store_identity_pin") {
window.__mockIdentityPins ??= ${JSON.stringify(opts.identityPins ?? {})};
window.__mockIdentityPins[String(args?.userId)] = args?.pin ?? null;
return null;
}
// ---- Window/webview plugin stubs ----
if (cmd.startsWith("plugin:window|") || cmd.startsWith("plugin:webview|")) return null;
@@ -321,9 +321,15 @@ test.describe("Voice E2EE identity verification (§7)", () => {
await expect(page.locator("h3", { hasText: "Identity Warning" })).toBeHidden();
// The EXACT displayed key was pinned (TOCTOU-safe re-pin), and the
// mismatch block cleared — the badge disappears until the next announce
// re-verifies against the new pin.
await expect(badge).toHaveCount(0);
// mismatch block cleared. Re-pinning replays the announce that was
// blocked as a mismatch (OC-0212), which re-verifies against the pin just
// stored — so the peer lands in the verified state rather than losing its
// badge entirely. The badge must not simply disappear: a mid-call peer
// never re-announces on its own, so an empty badge would mean the peer
// stayed un-keyed for the rest of the call while the UI showed nothing.
await expect(badge).toBeVisible();
await expect(badge).toHaveClass(/verified/);
await expect(badge).toHaveAttribute("title", /^Identity verified · Safety number: /);
const pins = await invokesOf(page, "store_identity_pin");
expect(pins).toHaveLength(1);
expect(pins[0]).toMatchObject({ userId: "2", pin: peer.identityPublicKeyB64 });
@@ -0,0 +1,85 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { buildAccessibilityTab } from "../../src/components/settings/AccessibilityTab";
/**
* OC-0232: "Reduce Motion" and "Sync with OS" are two independent writers of
* the `.reduced-motion` class with no arbitration. A manual toggle click
* writes the class directly, ignoring whether OS sync currently owns it —
* so turning "Reduce Motion" OFF while "Sync with OS" is ON and the OS still
* asks for reduced motion silently disables reduced motion app-wide.
*/
describe("AccessibilityTab — reduced-motion arbitration (OC-0232)", () => {
let container: HTMLDivElement;
let controller: AbortController;
let matchMediaListeners: Map<string, Function>;
const matchMediaMatches = true; // OS prefers reduced motion, for the whole test
function findToggle(label: string): HTMLElement {
const rows = container.querySelectorAll(".setting-row");
for (const row of Array.from(rows)) {
const labelEl = row.querySelector(".setting-label");
if (labelEl?.textContent === label) {
const toggle = row.querySelector('[role="switch"]');
if (toggle === null) {
throw new Error(`toggle not found for label "${label}"`);
}
return toggle as HTMLElement;
}
}
throw new Error(`row not found for label "${label}"`);
}
beforeEach(() => {
matchMediaListeners = new Map();
vi.spyOn(window, "matchMedia").mockImplementation((query: string) => {
const mql = {
matches: matchMediaMatches,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn((type: string, handler: Function) => {
matchMediaListeners.set(type, handler);
}),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(() => true),
} as unknown as MediaQueryList;
return mql;
});
localStorage.clear();
document.documentElement.classList.remove("reduced-motion");
controller = new AbortController();
container = buildAccessibilityTab(controller.signal);
document.body.appendChild(container);
});
afterEach(() => {
controller.abort();
container.remove();
document.documentElement.classList.remove("reduced-motion");
localStorage.clear();
vi.restoreAllMocks();
});
it("keeps reduced-motion applied when OS sync is on and the OS still prefers it, even after a manual Reduce Motion toggle is switched off", () => {
const syncToggle = findToggle("Sync with OS");
const motionToggle = findToggle("Reduce Motion");
// Turn on "Sync with OS": the OS prefers reduced motion, so the class
// should be applied by the media-query-driven listener.
syncToggle.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(document.documentElement.classList.contains("reduced-motion")).toBe(true);
// Manually flip "Reduce Motion" on, then off. "Sync with OS" is still
// on and the (mocked) OS still prefers reduced motion throughout, so
// the effective state must never change out from under it.
motionToggle.dispatchEvent(new MouseEvent("click", { bubbles: true })); // -> on
expect(document.documentElement.classList.contains("reduced-motion")).toBe(true);
motionToggle.dispatchEvent(new MouseEvent("click", { bubbles: true })); // -> off
expect(document.documentElement.classList.contains("reduced-motion")).toBe(true);
});
});
@@ -262,15 +262,20 @@ describe("AccessibilityTab", () => {
// -----------------------------------------------------------------------
describe("side effects", () => {
it("toggles reduced-motion class on documentElement for reducedMotion", () => {
it("routes reducedMotion through syncOsMotionListener rather than writing the class directly (OC-0232)", () => {
// os-motion.ts is the single writer of `.reduced-motion`; the Reduce
// Motion toggle must delegate to it (passing the current syncOsMotion
// pref) instead of touching documentElement itself, so a manual toggle
// can no longer fight the OS-sync listener. With os-motion mocked here,
// the real class-application behaviour is covered in AccessibilityTab.test.ts.
const section = buildAccessibilityTab(ac.signal);
container.appendChild(section);
clickToggle(container, 0);
expect(document.documentElement.classList.contains("reduced-motion")).toBe(true);
expect(mockSyncOsMotionListener).toHaveBeenLastCalledWith(false);
clickToggle(container, 0);
expect(document.documentElement.classList.contains("reduced-motion")).toBe(false);
expect(mockSyncOsMotionListener).toHaveBeenLastCalledWith(false);
});
it("toggles high-contrast class on documentElement for highContrast", () => {
@@ -0,0 +1,153 @@
// OC-0231: stopVadPolling() must detach the worklet's MessagePort handler so
// a "gate" message the worklet posts *after* stop() has already been sent
// (but before the audio thread has processed it) cannot re-gate the mic with
// no VAD left running to ever un-gate it again.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockLoadPref, mockSavePref } = vi.hoisted(() => ({
mockLoadPref: vi.fn((_key: string, defaultVal: unknown) => defaultVal),
mockSavePref: vi.fn(),
}));
vi.mock("@components/settings/helpers", () => ({
loadPref: (key: string, defaultVal: unknown) => mockLoadPref(key, defaultVal),
savePref: (key: string, val: unknown) => mockSavePref(key, val),
}));
vi.mock("@lib/logger", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("@lib/noise-suppression", () => ({
createRNNoiseProcessor: vi.fn(),
}));
vi.mock("livekit-client", () => ({
Track: {
Source: {
Microphone: "microphone",
Camera: "camera",
ScreenShare: "screenShare",
ScreenShareAudio: "screenShareAudio",
},
},
}));
import { AudioPipeline } from "../../src/lib/audioPipeline";
describe("AudioPipeline VAD worklet teardown (OC-0231)", () => {
let pipeline: AudioPipeline;
let mockGainNode: any;
let mockAnalyserNode: any;
let mockRoom: any;
beforeEach(() => {
vi.clearAllMocks();
pipeline = new AudioPipeline();
mockGainNode = {
gain: { value: 1, setValueAtTime: vi.fn(), setTargetAtTime: vi.fn() },
connect: vi.fn(),
disconnect: vi.fn(),
};
mockAnalyserNode = {
fftSize: 0,
smoothingTimeConstant: 0,
connect: vi.fn(),
disconnect: vi.fn(),
getFloatTimeDomainData: vi.fn(),
};
const mockDestNode = {
stream: { getAudioTracks: vi.fn().mockReturnValue([{ id: "track" }]) },
disconnect: vi.fn(),
};
const mockSourceNode = { connect: vi.fn() };
const mockAudioCtx = {
resume: vi.fn().mockResolvedValue(undefined),
createMediaStreamSource: vi.fn().mockReturnValue(mockSourceNode),
createAnalyser: vi.fn().mockReturnValue(mockAnalyserNode),
createGain: vi.fn().mockReturnValue(mockGainNode),
createMediaStreamDestination: vi.fn().mockReturnValue(mockDestNode),
currentTime: 0,
close: vi.fn().mockResolvedValue(undefined),
state: "running",
audioWorklet: { addModule: vi.fn().mockResolvedValue(undefined) },
};
vi.stubGlobal(
"AudioWorkletNode",
vi.fn().mockImplementation(() => ({
port: {
postMessage: vi.fn(),
onmessage: null as ((event: MessageEvent) => void) | null,
},
connect: vi.fn(),
disconnect: vi.fn(),
})),
);
vi.stubGlobal("AudioContext", vi.fn().mockReturnValue(mockAudioCtx));
vi.stubGlobal(
"MediaStream",
vi.fn().mockImplementation(() => ({})),
);
mockRoom = {
localParticipant: {
getTrackPublication: vi.fn().mockReturnValue({
track: {
mediaStreamTrack: { id: "track" },
sender: { replaceTrack: vi.fn().mockResolvedValue(undefined) },
getProcessor: vi.fn(),
setProcessor: vi.fn(),
stopProcessor: vi.fn(),
},
}),
},
};
mockLoadPref.mockImplementation((key: string, defaultVal: unknown) => {
if (key === "voiceSensitivity") return 50;
if (key === "inputVolume") return 100;
return defaultVal;
});
});
afterEach(() => {
pipeline.teardownAudioPipeline();
vi.unstubAllGlobals();
});
it("ignores a late 'gate:true' message delivered after stopVadPolling", async () => {
pipeline.setRoom(mockRoom);
pipeline.setupAudioPipeline();
await vi.waitFor(() => {
expect(pipeline.vadUsingWorklet).toBe(true);
});
const WorkletNodeConstructor = (globalThis as any).AudioWorkletNode;
const workletInstance = WorkletNodeConstructor.mock.results[0].value;
// sensitivity dragged to 100: setVoiceSensitivity(100) stops VAD without
// restarting it (mirrors audioPipeline.ts setVoiceSensitivity clamped>=100 branch)
pipeline.stopVadPolling();
expect(pipeline.isVadGated).toBe(false);
// The worklet's audio-thread process() loop was mid-flight when `stop`
// was posted and still emits one more "gate" message before it honors
// `_active = false`. That message arrives on the *same* port object the
// pipeline handed out, after stopVadPolling() already ran.
expect(workletInstance.port.onmessage).toBeNull();
workletInstance.port.onmessage?.({ data: { type: "gate", gated: true } } as MessageEvent);
// Must stay ungated — there is no VAD left running to ever undo this.
expect(pipeline.isVadGated).toBe(false);
// And the pipeline gain must not have been driven to 0 by the stale message.
mockGainNode.gain.setTargetAtTime.mockClear();
});
});
@@ -20,6 +20,7 @@ import type { ReadyChannel } from "../../src/lib/types";
import { acknowledgeNsfw, isNsfwAcknowledged } from "../../src/lib/nsfw-gate";
import { addLogListener, type LogEntry } from "@lib/logger";
import type { UserWithRole, MessageResponse, MessageUser } from "../../src/lib/types";
import { uiStore, setSidebarMode, setActiveDmUser } from "../../src/stores/ui.store";
// Mock the lazily-imported voice SDK module so we can assert clearAuth() only
// pulls it in (loading the ~1.3 MB LiveKit chunk) when a voice session exists.
@@ -516,4 +517,24 @@ describe("auth store", () => {
expect(channelsStore.getState().channels.has(999)).toBe(false);
});
});
// Regression: clearAuth() must also reset uiStore's sidebarMode and
// activeDmUserId, or a "dms" sidebar mode (and the previous server's DM
// peer id) survive a logout as module-global state and leak into the next
// signed-into server — SidebarArea.ts reads uiStore.getState().sidebarMode
// on initial mount, so the next server mounts the DM sidebar instead of its
// channel list even though nothing restated sidebarMode for the new session.
describe("clearAuth ui cleanup", () => {
it("resets sidebarMode to 'channels' and clears activeDmUserId on logout", () => {
setSidebarMode("dms");
setActiveDmUser(7);
expect(uiStore.getState().sidebarMode).toBe("dms");
expect(uiStore.getState().activeDmUserId).toBe(7);
clearAuth();
expect(uiStore.getState().sidebarMode).toBe("channels");
expect(uiStore.getState().activeDmUserId).toBeNull();
});
});
});
@@ -198,6 +198,64 @@ describe("startAutoIdle", () => {
expect(loadUserStatusOrigin()).toBe("manual");
});
it("stays armed after a firing that changed nothing, so a later external status change is still watched", () => {
// Regression for OC-0236: the timer callback used to leave `timer` at
// null forever after it fired once. That's invisible while the status
// stays untouched between firings, but a surface that writes
// saveUserStatus() directly instead of going through onActivity — the OS
// tray's Status submenu, which delivers no DOM event into the webview —
// can make the status eligible again (dnd -> online) without ever
// re-arming the watcher. Without re-arming, the user then stays broadcast
// as Online indefinitely.
saveUserStatus("dnd");
const onStatusChange = vi.fn();
const target = createTarget();
controller = startAutoIdle({ onStatusChange, target });
// First firing: ineligible (dnd), apply(true) is a no-op.
vi.advanceTimersByTime(AUTO_IDLE_DELAY_MS);
expect(onStatusChange).not.toHaveBeenCalled();
// The tray writes the status directly — no DOM event, so onActivity/arm()
// never runs on this path.
saveUserStatus("online", "manual");
// A further full delay of continued inactivity should now flip to idle,
// exactly as it would have if "online" had been the status from the
// start. That requires the timer to still be armed.
vi.advanceTimersByTime(AUTO_IDLE_DELAY_MS);
expect(onStatusChange).toHaveBeenCalledExactlyOnceWith("idle");
expect(loadUserStatus()).toBe("idle");
expect(loadUserStatusOrigin()).toBe("auto");
});
it("leaves no pending timer when destroy() is called synchronously from onStatusChange", () => {
// The re-arm added for OC-0236 runs after apply(true), which invokes
// onStatusChange synchronously. If that callback tears the page down and
// calls destroy() from inside it, `timer` is already null at that point
// (cleared before apply() ran), so destroy()'s own clearTimeout is a
// no-op. Without re-checking `destroyed` before the re-arm, destroy()
// would appear to work (no wrong status change ever fires, since the
// handler's own top-of-body check still catches it) while actually
// leaking a dangling timer that outlives the controller.
saveUserStatus("online");
const target = createTarget();
const onStatusChange = vi.fn(() => {
controller?.destroy();
controller = null;
});
// Baseline first: the environment (jsdom/vitest) may hold timers of its
// own that have nothing to do with this controller, so assert against a
// delta rather than an absolute count of 0.
const before = vi.getTimerCount();
controller = startAutoIdle({ onStatusChange, target });
expect(vi.getTimerCount()).toBe(before + 1);
vi.advanceTimersByTime(AUTO_IDLE_DELAY_MS);
expect(onStatusChange).toHaveBeenCalledExactlyOnceWith("idle");
expect(vi.getTimerCount()).toBe(before);
});
it("stops firing after destroy", () => {
saveUserStatus("online");
const onStatusChange = vi.fn();
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest";
import {
blocksStore,
setBlockedByMe,
setUserBlockedByMe,
setUserBlockedByThem,
clearBlockedByThem,
dmComposerBlockReason,
@@ -80,4 +81,46 @@ describe("blocksStore", () => {
expect(blocksStore.getState()).toBe(before);
});
});
// OC-0218: a ready-time GET /blocks and a user-initiated block/unblock can
// race. The GET is issued before the user's own action but its reply can
// land after — a stale full-set reply must not clobber a fresher per-user
// delta.
describe("setBlockedByMe staleness guard (OC-0218)", () => {
it("applies when no revision is given (direct/legacy caller)", () => {
setBlockedByMe([5]);
expect(dmComposerBlockReason(blocksStore.getState(), 5)).toBe(BLOCKED_BY_ME_REASON);
});
it("a reply carrying the revision observed before a fresher local delta must not re-add it", () => {
// Local user 42 starts blocked (seeded, as if from a previous ready).
setBlockedByMe([42]);
// A reconnect fires a fresh GET /blocks — the caller snapshots the
// revision it observed right before issuing the request. Real callers
// (dispatcher.ts) default the optional field to 0, exactly like
// setBlockedByMe's own internal comparison does.
const revBeforeFetch = blocksStore.getState().blockedByMeRev ?? 0;
// While that GET is in flight, the user clicks "Unblock" — this is the
// fresher, authoritative local truth.
setUserBlockedByMe(42, false);
expect(dmComposerBlockReason(blocksStore.getState(), 42)).toBeNull();
// The GET's reply lands late, still carrying the stale pre-unblock
// snapshot and the revision observed before the unblock. It must be
// ignored, not re-add 42.
setBlockedByMe([42], revBeforeFetch);
expect(dmComposerBlockReason(blocksStore.getState(), 42)).toBeNull();
});
it("a reply carrying the current revision still applies", () => {
setBlockedByMe([1]);
const rev = blocksStore.getState().blockedByMeRev;
// No local delta happened since — the snapshot is still current.
setBlockedByMe([1, 2], rev);
expect(dmComposerBlockReason(blocksStore.getState(), 1)).toBe(BLOCKED_BY_ME_REASON);
expect(dmComposerBlockReason(blocksStore.getState(), 2)).toBe(BLOCKED_BY_ME_REASON);
});
});
});
@@ -518,6 +518,7 @@ describe("createChannelController", () => {
// After a jump into history the composer stays enabled; sending must
// land the optimistic row in the live tail, not mid-history.
mockIsWindowDetached.mockReturnValueOnce(true);
mockMarkChannelRead.mockClear();
const opts = makeOpts();
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
@@ -530,6 +531,10 @@ describe("createChannelController", () => {
expect(opts.msgCtrl.loadMessages).toHaveBeenCalledWith(42, expect.any(AbortSignal));
// The send itself still goes out.
expect(opts.ws.send).toHaveBeenCalledWith(expect.objectContaining({ type: "chat_send" }));
// OC-0204: a detached-but-active channel can carry an unread/mention
// badge dispatcher.ts left behind for messages missed below the gap —
// jumping to present (which sending here implies) must clear it.
expect(mockMarkChannelRead).toHaveBeenCalledWith(42);
});
it("onSend while disconnected records a failed optimistic row (no silent drop)", () => {
@@ -582,6 +587,7 @@ describe("createChannelController", () => {
});
it("onJumpToPresent reattaches the channel and refetches the live tail", () => {
mockMarkChannelRead.mockClear();
const opts = makeOpts();
const ctrl = createChannelController(opts);
ctrl.mountChannel(42, "general");
@@ -596,6 +602,10 @@ describe("createChannelController", () => {
expect(mockReattachToPresent.mock.invocationCallOrder[0]).toBeLessThan(
(opts.msgCtrl.loadMessages as ReturnType<typeof vi.fn>).mock.invocationCallOrder[0]!,
);
// OC-0204: clicking "Jump to Present" is reading whatever arrived
// below the gap — clear the badge it may have left, the same way
// leaving a channel does.
expect(mockMarkChannelRead).toHaveBeenCalledWith(42);
});
it("onRetry re-sends the failed draft with a fresh correlation id", () => {
@@ -2001,6 +2001,23 @@ describe("ChannelSidebar voice identity badge", () => {
expect(title).toContain("not an identity");
});
it("refreshes the badge tooltip when a peer's session fingerprint changes at an unchanged status (OC-0208)", () => {
addVoiceUser(VOICE_CH, 10, "Alice");
setPeerVerif(10, "unverified", null, "5E55 1234 5678 9ABC");
sidebar.mount(container);
expect(badgeFor(10)!.getAttribute("title") ?? "").toContain("5E55 1234 5678 9ABC");
// Peer reconnects: LiveKit E2EE re-announces a fresh ephemeral keypair,
// producing a new session fingerprint while `status` stays "unverified".
setPeerVerif(10, "unverified", null, "9C71 8888 4444 2222");
voiceStore.flush();
const title = badgeFor(10)!.getAttribute("title") ?? "";
expect(title).toContain("9C71 8888 4444 2222");
expect(title).not.toContain("5E55 1234 5678 9ABC");
});
it("shows the local user's own session fingerprint on their voice row", () => {
authStore.setState((prev) => ({
...prev,
@@ -2426,3 +2443,75 @@ describe("ChannelSidebar channel context menu permissions", () => {
expect(menu?.querySelector('[data-testid="ctx-edit-channel"]')).not.toBeNull();
});
});
// ── Per-row listeners must not outlive the render that created them (OC-0229) ──
//
// renderChannels() does clearChildren(channelList) and rebuilds every row from
// scratch on every channels-store notification (a new unread count, a new
// active channel, a role change, ...). Each row's listeners (context menu,
// drag handlers, ...) used to be registered on the sidebar's single
// factory-lifetime AbortSignal, which only aborts once, in destroy(). That
// signal's "abort" algorithm list is what actually keeps a DOM node alive in
// a browser once addEventListener({ signal }) has been called on it, so a
// detached row whose listener is still registered on that signal is retained
// for the sidebar's entire lifetime instead of being collectable after the
// re-render that replaced it.
//
// This cannot observe GC directly in jsdom, but the retained listener is
// itself observable: a detached row whose "contextmenu" listener is still
// live will still open a context menu when the event fires on it, even
// though the row has not been part of the document since the render that
// superseded it.
describe("ChannelSidebar row listeners across re-renders (OC-0229)", () => {
let container: HTMLDivElement;
let sidebar: ReturnType<typeof createChannelSidebar>;
beforeEach(() => {
resetStores();
container = document.createElement("div");
document.body.appendChild(container);
sidebar = createChannelSidebar({ onVoiceJoin: vi.fn(), onVoiceLeave: vi.fn() });
});
afterEach(() => {
sidebar.destroy?.();
container.remove();
document.querySelectorAll(".channel-ctx-menu").forEach((el) => el.remove());
});
it("does not leave a stale row's context-menu listener live after a re-render replaces it", () => {
setChannels(testChannels);
sidebar.mount(container);
const staleRow = container.querySelector('[data-channel-id="1"]') as HTMLElement;
expect(staleRow).not.toBeNull();
// Provoke renderChannels() the same way incrementUnread does for every
// message delivered to a non-active channel: a fresh channels Map with
// fresh Channel object references flows through the `s.channels`
// selector, which is not shallow-equal to the previous one.
setChannels(testChannels);
channelsStore.flush();
// clearChildren(channelList) detached the old row and a new one replaced it.
const freshRow = container.querySelector('[data-channel-id="1"]') as HTMLElement;
expect(freshRow).not.toBeNull();
expect(freshRow).not.toBe(staleRow);
expect(staleRow.isConnected).toBe(false);
// The stale, detached row must not still be able to open a menu -- if it
// does, its listener is still registered (on a signal that only aborts at
// sidebar destroy()), which is the retention this finding is about.
staleRow.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 4 }),
);
expect(document.querySelector(".channel-ctx-menu")).toBeNull();
// The replacement row must still work normally -- the fix must scope the
// listener to the render, not break the context menu outright.
freshRow.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 4, clientY: 4 }),
);
expect(document.querySelector(".channel-ctx-menu")).not.toBeNull();
});
});
@@ -662,6 +662,17 @@ describe("syntax highlighting", () => {
expect(resolveLanguage(null)).toBeNull();
});
it("rejects Object.prototype property names as fence tags", () => {
expect(resolveLanguage("constructor")).toBeNull();
expect(resolveLanguage("toString")).toBeNull();
expect(resolveLanguage("valueOf")).toBeNull();
expect(resolveLanguage("hasOwnProperty")).toBeNull();
expect(resolveLanguage("isPrototypeOf")).toBeNull();
expect(resolveLanguage("propertyIsEnumerable")).toBeNull();
expect(resolveLanguage("toLocaleString")).toBeNull();
expect(resolveLanguage("__proto__")).toBeNull();
});
it("returns one plain token for an unknown language", () => {
expect(highlightCode("anything", null)).toEqual([{ text: "anything", cls: null }]);
});
@@ -20,7 +20,7 @@ import {
import { membersStore } from "../../src/stores/members.store";
import { voiceStore } from "../../src/stores/voice.store";
import { dmStore } from "../../src/stores/dm.store";
import { blocksStore } from "../../src/stores/blocks.store";
import { blocksStore, setUserBlockedByMe } from "../../src/stores/blocks.store";
import {
emojiStore,
setCustomEmoji,
@@ -85,6 +85,8 @@ import {
leaveVoice as mockLeaveVoice,
disableCamera as mockDisableCamera,
disableScreenshare as mockDisableScreenshare,
isVoiceConnected as mockIsVoiceConnected,
handleParticipantLeft as mockHandleParticipantLeft,
} from "@lib/livekitSession";
import { rollbackPendingVideo as mockRollbackPendingVideo } from "@lib/screenShare";
@@ -430,6 +432,53 @@ describe("WS Dispatcher", () => {
expect(ch?.unreadCount).toBe(1);
});
// OC-0204: "active channel" normally means "the user is watching the live
// tail", so skipping the unread bump there is correct — until a jump to an
// old permalink/reply/search hit leaves the SAME active channel showing a
// detached around-window (messages.store's detachedChannels). addMessage
// already refuses to append a live broadcast onto a detached window, so
// without also bumping the badge here, a message arriving while the user
// reads back-history leaves no row AND no badge — nothing records it ever
// arrived.
it("wires chat_message to increment unread for the active channel when its window is detached", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(5, {
id: 5,
name: "general",
type: "text" as const,
category: null,
position: 0,
unreadCount: 0,
mentionCount: 0,
lastMessageId: null,
canSend: true,
topic: "",
slowMode: 0,
nsfw: false,
voiceMaxUsers: 0,
voiceMaxVideo: 0,
});
return { ...prev, channels: ch, activeChannelId: 5 }; // channel 5 IS active...
});
// ...but its loaded window is detached from the live tail (viewing
// back-history via a jump).
messagesStore.setState((prev) => ({ ...prev, detachedChannels: new Set([5]) }));
mock.dispatch("chat_message", {
id: 200,
channel_id: 5,
user: { id: 2, username: "bob", avatar: null },
content: "ping",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
const ch = channelsStore.getState().channels.get(5);
expect(ch?.unreadCount).toBe(1);
});
describe("chat_message notifications during a reconnect replay burst", () => {
// The server writes auth_ok before the replay burst, so by the time
// replayed chat_message frames arrive the client is already "connected"
@@ -3282,6 +3331,44 @@ describe("WS Dispatcher", () => {
expect([...blocksStore.getState().blockedByMe]).toEqual([11, 22]);
});
// OC-0218: the ready-time GET /blocks and a user-initiated block/unblock
// (SidebarMemberSection's onToggleBlock -> setUserBlockedByMe, after its
// own await api.blockUser/unblockUser) can race. The GET is issued first
// but its reply can land after the user's own fresher action — applying it
// unconditionally reverts what the user just did.
it("does not let a slow-to-resolve ready-time listBlocks revert a fresher local unblock", async () => {
cleanup(); // tear down the no-api dispatcher wired in beforeEach
let resolveListBlocks!: (v: { blocked_user_ids: number[] }) => void;
const listBlocks = vi.fn(
() =>
new Promise<{ blocked_user_ids: number[] }>((resolve) => {
resolveListBlocks = resolve;
}),
);
cleanup = wireDispatcher(mock.ws, { listBlocks });
// Local user 42 is blocked from a previous session.
blocksStore.setState(() => ({ blockedByMe: new Set([42]), blockedByThem: new Set() }));
// Reconnect: ready fires the GET, which does not resolve yet.
mock.dispatch("ready", { channels: [], members: [], voice_states: [], roles: [] });
expect(listBlocks).toHaveBeenCalled();
// While it's in flight, the user clicks "Unblock" on 42 — the same
// sequence SidebarMemberSection's onToggleBlock performs once its own
// await api.unblockUser resolves.
setUserBlockedByMe(42, false);
expect([...blocksStore.getState().blockedByMe]).toEqual([]);
// The GET finally resolves with the stale pre-unblock snapshot.
resolveListBlocks({ blocked_user_ids: [42] });
await Promise.resolve();
await Promise.resolve();
// The user's unblock must win — 42 must not be silently re-added.
expect([...blocksStore.getState().blockedByMe]).toEqual([]);
});
it("wires a local transport send failure to mark the pending row failed", () => {
uiStore.setState((prev) => ({ ...prev, transientError: null }));
@@ -3440,6 +3527,34 @@ describe("WS Dispatcher", () => {
expect(dm?.unreadCount).toBe(0);
});
// OC-0204's DM-path sibling: the same "active means watching the live
// tail" assumption governs isDmActive here, and breaks the same way when
// the active DM's loaded window is detached (a jump to an old permalink/
// search hit inside the conversation).
it("updates DM last message WITH unread when the active DM's window is detached", () => {
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 50 }));
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
messagesStore.setState((prev) => ({ ...prev, detachedChannels: new Set([50]) }));
mock.dispatch("chat_message", {
id: 503,
channel_id: 50,
user: { id: 10, username: "bob", avatar: "" },
content: "arrived while reading back-history",
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
const dms = dmStore.getState().channels;
const dm = dms.find((c) => c.channelId === 50);
expect(dm?.lastMessage).toBe("arrived while reading back-history");
expect(dm?.unreadCount).toBe(1);
});
it("increments the DM mention badge for an incoming @mention", () => {
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 }));
authStore.setState((prev) => ({
@@ -3982,6 +4097,83 @@ describe("WS Dispatcher", () => {
expect(voiceLeaveSent).toBe(false);
});
// OC-0201: a full-ready resync that preserves a live voice session (the
// LiveKit room outlived a WS drop) never replays voice_leave for anyone
// who departed the channel while the socket was down — `ready` rebuilds
// voiceUsers wholesale and stops. Without reconciliation, a departed peer
// keeps a working room key forever (no rotation ever runs for them) and a
// client newly elected key holder by the server-side re-registration never
// self-elects, since only handleParticipantLeft runs the election.
it("reconciles E2EE state for peers who left during a full-ready resync with a live voice session", async () => {
vi.mocked(mockHandleParticipantLeft).mockClear();
authStore.setState(() => ({
token: "test-token",
user: { id: 42, username: "me", avatar: null, role: "member" },
serverName: "Test",
motd: "",
isAuthenticated: true,
}));
// Before the resync: self (42) and peer (7) are both in channel 10 — the
// live LiveKit session survived the WS drop.
voiceStore.setState((prev) => ({
...prev,
voiceStatus: "connected",
currentChannelId: 10,
voiceUsers: new Map([
[
10,
new Map([
[
42,
{
userId: 42,
username: "me",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
serverMuted: false,
serverDeafened: false,
},
],
[
7,
{
userId: 7,
username: "departed",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
serverMuted: false,
serverDeafened: false,
},
],
]),
],
]),
}));
// The full resync's voice_states shows peer 7 has left channel 10 while
// we were disconnected — only self remains.
mock.dispatch("ready", {
channels: [{ id: 1, name: "general", type: "text", category: "", position: 0 }],
members: [],
voice_states: [{ user_id: 42, channel_id: 10, muted: false, deafened: false }],
roles: [],
dm_channels: [],
});
await vi.runAllTimersAsync();
expect(mockHandleParticipantLeft).toHaveBeenCalledWith(7);
// Must never be called for ourselves.
expect(mockHandleParticipantLeft).not.toHaveBeenCalledWith(42);
});
it("unknown event type does not throw", () => {
expect(() => {
mock.dispatch("totally_unknown_server_event", { some: "data" });
@@ -4109,6 +4301,31 @@ describe("WS Dispatcher", () => {
expect(voiceStore.getState().currentChannelId).toBeNull();
expect(voiceStore.getState().voiceStatus).toBe("idle");
});
// OC-0193: a channel *switch* refusal (precheck FORBIDDEN/BAD_REQUEST/
// RATE_LIMITED — anything that lands before the server's self voice_leave
// for the OLD channel) optimistically moved currentChannelId to the NEW
// channel and voiceStatus to "joining", but the LiveKit room from the OLD
// channel is still live — connected, mic published. The store-only
// leaveVoiceChannel() rollback used to leave that session dangling: the
// widget disappears (currentChannelId null hides it entirely) while audio
// keeps flowing and the server still lists us in the old channel. The
// rollback must also tear down the live LiveKit session so the media
// state and the store agree.
it("tears down a still-live LiveKit session when a channel-switch join is refused", async () => {
vi.mocked(mockLeaveVoice).mockClear();
vi.mocked(mockIsVoiceConnected).mockReturnValue(true);
voiceStore.setState((prev) => ({ ...prev, currentChannelId: 7, voiceStatus: "joining" }));
mock.dispatch("error", { code: "FORBIDDEN", message: "missing CONNECT_VOICE permission" });
await vi.runAllTimersAsync();
expect(mockLeaveVoice).toHaveBeenCalledWith(true);
expect(voiceStore.getState().currentChannelId).toBeNull();
expect(voiceStore.getState().voiceStatus).toBe("idle");
vi.mocked(mockIsVoiceConnected).mockReturnValue(false);
});
});
// A server refusal of voice_camera/voice_screenshare (FORBIDDEN,
@@ -86,6 +86,23 @@ describe("dmDisplayName", () => {
it("falls back to the recipient when the participant list is empty", () => {
expect(dmDisplayName(makeDm({ participants: [] }))).toBe("bob");
});
// Regression (OC-0220): a group DM that has lost every other member still
// has a live, is_group=1 channel row (LeaveGroupDM only deletes the row
// when the LAST member leaves), but the server never populates `recipient`
// for a channel with zero "other" participants — it stays the zero-valued
// DMUser (username ""). Falling back to that empty username renders a
// blank label everywhere dmDisplayName is used.
it("never renders blank for a group that has lost every other member", () => {
const name = dmDisplayName(
makeDm({
isGroup: true,
participants: [],
recipient: { id: 0, username: "", avatar: "", status: "" },
}),
);
expect(name).not.toBe("");
});
});
// ---------------------------------------------------------------------------
@@ -335,6 +335,28 @@ describe("dmStore", () => {
expect(dmStore.getState().channels[1]!.unreadCount).toBe(2);
expect(dmStore.getState().channels[1]!.lastMessageId).toBeNull();
});
// Regression (OC-0224): on a fresh connect, registerNow (subscribing the
// client) runs before buildReady snapshots unread_count, so a DM that
// lands in that window is counted once by `ready` and then delivered
// again as a queued chat_message. Applying `ready` already sets
// unreadCount/lastMessageId to that message; a second call for the SAME
// message id must not double-count it.
it("does not double-count a message id already reflected by the last ready snapshot", () => {
setDmChannels([makeDm({ channelId: 5, unreadCount: 1, lastMessageId: 42 })]);
updateDmLastMessage(5, 42, "hello", "2026-03-28T12:00:00Z");
const ch = dmStore.getState().channels[0]!;
expect(ch.unreadCount).toBe(1);
});
// A stale/out-of-order redelivery of an older message must not bump the
// badge either.
it("does not count a message id older than the channel's last message", () => {
setDmChannels([makeDm({ channelId: 5, unreadCount: 2, lastMessageId: 50 })]);
updateDmLastMessage(5, 42, "stale", "2026-03-28T12:00:00Z");
const ch = dmStore.getState().channels[0]!;
expect(ch.unreadCount).toBe(2);
});
});
// ── updateDmLastMessagePreview ──────────────────────────
@@ -1629,4 +1629,135 @@ describe("E2EEManager", () => {
vi.useRealTimers();
}
});
// ── Ledger findings OC-0209 / OC-0212 / OC-0213 ───────────────────────────
it("[OC-0209] rejects a replayed retired-key announce before it overwrites the peer's verification badge", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
await mgr.setupKeyExchange(true, 1); // establishes our keypair, holder
// Make import/export round-trip faithfully on the announced base64
// string (the shared mock default returns a fixed constant regardless
// of input, which would mask this bug).
vi.mocked(importPublicKey).mockImplementation(
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
);
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
(key as unknown as { type: string }).type.replace("peer-key-", ""),
);
const KEY_A = "b2xk";
const KEY_B = "bmV3";
try {
// Peer announces key A, then a genuine key change to B — A is now retired.
await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA");
await mgr.handleAnnounce(PEER_ID, KEY_B, "sigB");
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` });
vi.mocked(setPeerVerification).mockClear();
// A malicious relay replays the old, still validly-signed announce for
// the retired key A. The replay guard must reject it BEFORE any
// verification write — a replay that reaches verifyPeerAnnounce first
// would overwrite the peer's badge (status + sessionFingerprint) with
// the retired key's, even though the guard then rejects the announce
// and _peerPublicKeys is left untouched.
await mgr.handleAnnounce(PEER_ID, KEY_A, "sigA");
expect(setPeerVerification).not.toHaveBeenCalled();
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY_B}` });
} finally {
vi.mocked(importPublicKey).mockImplementation(
async () => ({ type: "public" }) as unknown as CryptoKey,
);
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
}
});
it("[OC-0212] replays the blocked announce after a successful re-pin, restoring the peer instead of leaving them un-keyed with the badge cleared", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
await mgr.setupKeyExchange(true, 1); // holder in channel 1
const NEW_IDENTITY = "new-identity-key-b64";
mockMembers.set(PEER_ID, { identityPublicKey: NEW_IDENTITY });
// The peer reinstalled (new identity key). A still has them pinned to
// their OLD identity key, so the announce under the new identity is
// blocked as a TOFU mismatch.
vi.mocked(getIdentityPin).mockResolvedValueOnce({
status: "pinned",
pin: "old-identity-key-b64",
});
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
expect(setPeerVerification).toHaveBeenCalledWith(
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
);
vi.mocked(setPeerVerification).mockClear();
// The user confirms the fingerprint out of band and re-pins to the
// peer's new identity key.
vi.mocked(getIdentityPin).mockResolvedValueOnce({ status: "pinned", pin: NEW_IDENTITY });
const result = await mgr.rePinPeerIdentity(PEER_ID, NEW_IDENTITY);
expect(result).toBe(true);
// The blocked announce must be replayed against the new pin — not just
// discarded with the badge cleared — so the peer actually re-enters
// _peerPublicKeys and is offered the room key for the rest of the call.
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
expect(setPeerVerification).toHaveBeenCalledWith(
expect.objectContaining({ userId: PEER_ID, status: "verified" }),
);
});
it("[OC-0213] does not permanently retire a peer's key on a stale voice_leave when the peer is still listed as present in the channel roster", async () => {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
await mgr.setupKeyExchange(true, 1); // holder in channel 1
vi.mocked(importPublicKey).mockImplementation(
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
);
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
(key as unknown as { type: string }).type.replace("peer-key-", ""),
);
const KEY = "b2xk";
try {
// The peer's rejoin announce (carrying a fresh key) arrives first —
// the OC-0213 repro's reordering, where the directly-published
// voice_e2ee_announce overtakes the still-queued voice_leave for the
// join instance it superseded.
await mgr.handleAnnounce(PEER_ID, KEY, "sig");
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(true);
// voice_state (the local roster) still lists the peer as present in
// the channel — this is what tells apart a stale, lagging leave for a
// superseded join instance from a genuine departure.
mockVoiceState.voiceUsers.set(1, new Map([[PEER_ID, {}]]));
// The stale voice_leave for the superseded join instance now arrives.
await mgr.handleParticipantLeft(PEER_ID);
// Removed from the live peer map (unchanged behavior)...
expect(mgr.peerPublicKeys.has(PEER_ID)).toBe(false);
// ...but the key must not be permanently retired: the peer's own,
// still-valid re-announce of the SAME key must be accepted again, not
// rejected as a replay of a "retired" key — otherwise the peer is
// stranded, un-re-announceable, for the rest of the call.
await mgr.handleAnnounce(PEER_ID, KEY, "sig");
expect(mgr.peerPublicKeys.get(PEER_ID)).toEqual({ type: `peer-key-${KEY}` });
} finally {
vi.mocked(importPublicKey).mockImplementation(
async () => ({ type: "public" }) as unknown as CryptoKey,
);
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
}
});
});
@@ -405,6 +405,47 @@ describe("LogsTab", () => {
expect(copiedText).toContain('"key"');
});
it("clear button updates the entry count, not just the list", () => {
mockGetLogBuffer.mockReturnValue([makeMockEntry("info", "one"), makeMockEntry("info", "two")]);
const handle = createLogsTab(() => "Logs" as TabName, controller.signal);
const el = handle.build();
expect(el.textContent).toContain("2 entries");
// Simulate clearLogBuffer() actually emptying the buffer.
mockClearLogBuffer.mockImplementation(() => {
mockGetLogBuffer.mockReturnValue([]);
});
const clearBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "Clear Logs",
)!;
clearBtn.click();
expect(el.querySelectorAll(".log-entry").length).toBe(0);
expect(el.textContent).toContain("0 entries");
expect(el.textContent).not.toContain("2 entries");
});
it("Refresh button updates the entry count to match the refreshed list", () => {
mockGetLogBuffer.mockReturnValue([makeMockEntry("info", "initial")]);
const handle = createLogsTab(() => "Logs" as TabName, controller.signal);
const el = handle.build();
expect(el.textContent).toContain("1 entries");
mockGetLogBuffer.mockReturnValue([
makeMockEntry("info", "initial"),
makeMockEntry("warn", "new entry"),
]);
const refreshBtn = Array.from(el.querySelectorAll("button")).find(
(b) => b.textContent === "Refresh",
)!;
refreshBtn.click();
expect(el.textContent).toContain("2 entries");
expect(el.textContent).not.toContain("1 entries");
});
it("Refresh Diagnostics button re-renders diagnostics panel", () => {
mockGetLogBuffer.mockReturnValue([]);
const handle = createLogsTab(() => "Logs" as TabName, controller.signal);
+254 -13
View File
@@ -20,9 +20,19 @@ vi.mock("@lib/logger", () => ({
}),
}));
const { capturedOnRemoteVideo } = vi.hoisted(() => ({
capturedOnRemoteVideo: {
current: null as null | ((userId: number, stream: MediaStream, isScreenshare: boolean) => void),
},
}));
vi.mock("@lib/livekitSession", () => ({
cleanupAll: vi.fn(),
setOnRemoteVideo: vi.fn(),
setOnRemoteVideo: vi.fn(
(cb: (userId: number, stream: MediaStream, isScreenshare: boolean) => void) => {
capturedOnRemoteVideo.current = cb;
},
),
setOnRemoteVideoRemoved: vi.fn(),
clearOnRemoteVideo: vi.fn(),
setWsClient: vi.fn(),
@@ -93,6 +103,15 @@ const {
videoGridSlot: HTMLDivElement;
};
dmProfileSlot: HTMLDivElement;
videoGrid: {
addStream: ReturnType<typeof vi.fn>;
removeStream: ReturnType<typeof vi.fn>;
clearStreams: ReturnType<typeof vi.fn>;
hasStreams: ReturnType<typeof vi.fn>;
setFocusedTile: ReturnType<typeof vi.fn>;
getFocusedTileId: ReturnType<typeof vi.fn>;
setLabel: ReturnType<typeof vi.fn>;
};
},
},
}));
@@ -132,20 +151,22 @@ vi.mock("../../src/pages/main-page/ChatArea", () => ({
videoGridSlot: document.createElement("div"),
};
const dmProfileSlot = document.createElement("div");
capturedChatAreaRef.current = { slots, dmProfileSlot };
const videoGrid = {
addStream: vi.fn(),
removeStream: vi.fn(),
clearStreams: vi.fn(),
hasStreams: vi.fn(() => false),
setFocusedTile: vi.fn(),
getFocusedTileId: vi.fn(() => null),
setLabel: vi.fn(),
mount: vi.fn(),
destroy: vi.fn(),
};
capturedChatAreaRef.current = { slots, dmProfileSlot, videoGrid };
return {
chatArea: document.createElement("div"),
slots,
videoGrid: {
addStream: vi.fn(),
removeStream: vi.fn(),
clearStreams: vi.fn(),
hasStreams: vi.fn(() => false),
setFocusedTile: vi.fn(),
getFocusedTileId: vi.fn(() => null),
mount: vi.fn(),
destroy: vi.fn(),
},
videoGrid,
chatHeaderName: document.createElement("span"),
chatHeaderRefs: {
hashEl: document.createElement("span"),
@@ -165,8 +186,9 @@ import { createMainPage } from "../../src/pages/MainPage";
import { channelsStore, setChannels, setActiveChannel } from "../../src/stores/channels.store";
import { authStore } from "../../src/stores/auth.store";
import { uiStore } from "../../src/stores/ui.store";
import { voiceStore } from "../../src/stores/voice.store";
import { voiceStore, updateVoiceUserProfile } from "../../src/stores/voice.store";
import { dmStore } from "../../src/stores/dm.store";
import { membersStore, updateMemberProfile } from "../../src/stores/members.store";
import type { WsClient, WsListener, ConnectionState } from "../../src/lib/ws";
import type { ApiClient } from "../../src/lib/api";
import type { ServerMessage } from "../../src/lib/types";
@@ -196,6 +218,7 @@ function resetStores(): void {
voiceStatus: "idle",
}));
dmStore.setState(() => ({ channels: [] }));
membersStore.setState(() => ({ members: new Map(), typingUsers: new Map(), roleRevision: 0 }));
}
type FakeWsClient = WsClient & {
@@ -383,6 +406,175 @@ describe("MainPage — video grid, DM profile panel, calls, settings", () => {
expect(capturedChatAreaRef.current!.slots.messagesSlot.style.display).toBe("");
});
it("does not clear a just-added remote video tile when a voice-channel switch left the camera/screenshare signature unchanged (OC-0207)", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(1, textChannel(1, "general"));
return { ...prev, channels: ch, activeChannelId: 1 };
});
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
page.mount(container);
// Alice joins voice channel A (9). Someone's camera briefly toggles the
// signature so the real VideoModeController actually runs checkVideoMode
// against channel 9 and latches its lastChannelId there — mirroring
// "Alice is already in voice channel A" from the finding's repro.
voiceStore.setState((prev) => ({
...prev,
currentChannelId: 9,
voiceUsers: new Map([
[
9,
new Map([
[
100,
{
userId: 100,
username: "carl",
muted: false,
deafened: false,
speaking: false,
camera: true,
screenshare: false,
},
],
]),
],
]),
}));
voiceStore.flush();
voiceStore.setState((prev) => ({
...prev,
voiceUsers: new Map([
[
9,
new Map([
[
100,
{
userId: 100,
username: "carl",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
},
],
]),
],
]),
}));
voiceStore.flush();
// Alice switches to voice channel B (10). Nobody in B has camera or
// screenshare on either, so MainPage's camera/screenshare signature does
// not change across the switch — the blind spot the finding describes.
voiceStore.setState((prev) => ({
...prev,
currentChannelId: 10,
voiceUsers: new Map([[10, new Map()]]),
}));
voiceStore.flush();
// The switch itself may legitimately clear stale tiles from channel A
// (that is the correct, eager fix) — what matters for this finding is
// that nothing clears the grid again *after* the new tile is added.
const videoGrid = capturedChatAreaRef.current!.videoGrid;
videoGrid.clearStreams.mockClear();
// Bob's screenshare track arrives via LiveKit in channel B ahead of the
// server's voice_state broadcast (the documented TrackSubscribed race).
const fakeStream = {} as MediaStream;
capturedOnRemoteVideo.current!(200, fakeStream, true);
expect(videoGrid.addStream).toHaveBeenCalled();
// The bug: VideoModeController's lastChannelId is still stuck on channel
// A (9) because the switch to B (10) never changed the signature, so the
// checkVideoMode() call right after addStream sees a "channel change"
// that isn't one and wipes the tile it was just given.
expect(videoGrid.clearStreams).not.toHaveBeenCalled();
});
it("relabels a remote video tile with the member's display name, not the raw username, and keeps it in sync with a mid-call rename (OC-0227)", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(1, textChannel(1, "general"));
return { ...prev, channels: ch, activeChannelId: 1 };
});
membersStore.setState(() => ({
members: new Map([
[
200,
{
id: 200,
username: "bob_1994",
avatar: null,
role: "member",
status: "online" as const,
displayName: "Bee",
},
],
]),
typingUsers: new Map(),
roleRevision: 0,
}));
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
page.mount(container);
voiceStore.setState((prev) => ({
...prev,
currentChannelId: 9,
voiceUsers: new Map([
[
9,
new Map([
[
200,
{
userId: 200,
username: "bob_1994",
muted: false,
deafened: false,
speaking: false,
camera: true,
screenshare: false,
},
],
]),
],
]),
}));
voiceStore.flush();
const fakeStream = {} as MediaStream;
capturedOnRemoteVideo.current!(200, fakeStream, false);
const videoGrid = capturedChatAreaRef.current!.videoGrid;
// The tile must show the same identity the voice roster and every other
// surface show for user 200 — the nickname "Bee" — not the raw username
// "bob_1994" (ChannelSidebar.ts's memberDisplayName idiom).
expect(videoGrid.addStream).toHaveBeenCalledWith(
200,
"Bee",
fakeStream,
expect.objectContaining({ isSelf: false }),
);
// Bob renames himself mid-call (Settings -> Account). The USER_UPDATE
// fan-out updates both membersStore and voiceStore's frozen username
// copy, exactly like dispatcher.ts's USER_UPDATE handler does.
updateMemberProfile(200, { username: "bob_1994", avatar: null, displayName: "Robert" });
updateVoiceUserProfile(200, { username: "bob_1994" });
voiceStore.flush();
// The already-open tile must pick up the new name without the tile
// being torn down and re-created (no new addStream call for tile 200).
expect(videoGrid.setLabel).toHaveBeenCalledWith(200, "Robert");
});
it("does not open the 1:1 profile panel for a group DM header click", () => {
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
@@ -527,6 +719,55 @@ describe("MainPage — video grid, DM profile panel, calls, settings", () => {
expect(banner.style.display).toBe("none");
});
it("does not cancel a group-DM ring when the ringer leaves voice but another callee is still in the call (OC-0235)", () => {
const ws = fakeWs();
uiStore.setState((prev) => ({ ...prev, connectionStatus: "connected" }));
page = createMainPage({ ws, api: fakeApi() });
page.mount(container);
// Alice (10) rings a group DM (channel 50); this client is a third
// participant (C).
ws.emit("call_incoming", { channel_id: 50, from_user: 10, username: "alice" });
const banner = document.querySelector('[data-testid="incoming-call-banner"]') as HTMLElement;
expect(banner.style.display).not.toBe("none");
// Bob (11) already accepted and is sitting in the DM's voice channel.
// The dispatcher's own voice_leave handler may already have removed
// Alice from the roster by the time this fires (order-independent), so
// her entry is absent here too — only Bob remains.
voiceStore.setState((prev) => ({
...prev,
voiceUsers: new Map([
[
50,
new Map([
[
11,
{
userId: 11,
username: "bob",
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
},
],
]),
],
]),
}));
voiceStore.flush();
// Alice, the ringer, hangs up. The call is still live — Bob is in it —
// so this client's own one-click Accept must not disappear.
ws.emit("voice_leave", { channel_id: 50, user_id: 10 });
expect(banner.style.display).not.toBe("none");
});
it("clears settingsOpen on destroy so the next page (e.g. ConnectPage after logout) doesn't inherit a stale open overlay", () => {
page = createMainPage({ ws: fakeWs(), api: fakeApi() });
page.mount(container);
@@ -1252,6 +1252,26 @@ describe("media.ts", () => {
const urls = extractUrls("http://insecure.com https://secure.com");
expect(urls).toEqual(["http://insecure.com", "https://secure.com"]);
});
it("strips sentence-ending trailing punctuation, matching the linkifier", () => {
const urls = extractUrls("Nice pic https://cdn.example.com/a.png.");
expect(urls).toEqual(["https://cdn.example.com/a.png"]);
});
it("strips a wrapping close-paren but keeps a balanced one from the URL itself", () => {
const wrapped = extractUrls("(https://cdn.example.com/a.png)");
expect(wrapped).toEqual(["https://cdn.example.com/a.png"]);
const balanced = extractUrls(
"See https://en.wikipedia.org/wiki/Rust_(programming_language) for details",
);
expect(balanced).toEqual(["https://en.wikipedia.org/wiki/Rust_(programming_language)"]);
});
it("strips trailing punctuation from a YouTube link so it resolves to a valid video id", () => {
const urls = extractUrls("Check https://youtu.be/dQw4w9WgXcQ.");
expect(urls).toEqual(["https://youtu.be/dQw4w9WgXcQ"]);
});
});
// =========================================================================
@@ -145,6 +145,17 @@ describe("@mention rendering", () => {
const el = render("hi @alice", { mentions: [20] });
expect(el.querySelector(".mention")?.getAttribute("data-user-id")).toBe("20");
});
it("does not fall back to the member list when the server sent a mentions list that omits the token (OC-0228)", () => {
// The server resolved this message's mentions to [10] (alice) only, not
// the signed-in user (id 12, username "me"). The inline pill must agree
// with the row-level gate and stay unresolved for "@me" here — a token
// the server did not list must not render as a live mention, let alone a
// self one.
const el = render("hey @me", { mentions: [10] });
expect(el.querySelector(".mention")).toBeNull();
expect(el.textContent).toBe("hey @me");
});
});
describe("@everyone / @here", () => {
@@ -1162,6 +1162,36 @@ describe("MessageInput", () => {
comp.destroy?.();
});
// ── Attachment count cap (server hard-rejects >10 attachments) ──
it("refuses to queue an 11th attachment instead of uploading it", async () => {
const onUploadFile = vi.fn(async () => ({ id: "x", url: "http://x", filename: "x" }));
const opts = makeOptions({ onUploadFile });
const comp = createMessageInput(opts);
comp.mount(container);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
for (let i = 0; i < 11; i++) {
const file = new File(["data"], `file${i}.txt`, { type: "text/plain" });
Object.defineProperty(fileInput, "files", { value: [file], writable: true });
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((r) => setTimeout(r, 10));
}
// The server hard-rejects a chat_send with more than 10 attachments (as
// a parse error with no attachment-specific messaging), so the composer
// must never upload -- let alone queue -- an 11th one.
expect(onUploadFile).toHaveBeenCalledTimes(10);
expect(container.querySelectorAll(".attachment-preview-item").length).toBe(10);
const error = container.querySelector(".attachment-upload-error");
expect(error).not.toBeNull();
expect(error!.textContent).toContain("10");
comp.destroy?.();
});
// ── Toggling emoji picker closed ──
it("clicking emoji button again closes the picker", () => {
@@ -252,6 +252,31 @@ describe("MessageList", () => {
expect(container.querySelector('[data-testid="message-150"]')).not.toBeNull();
});
it("OC-0217: repeated jumps do not each register a permanent abort listener on the component-lifetime signal", () => {
// As a user clicking a reply bar's jump arrow, a search hit, or a pinned
// entry repeatedly does across a live session.
const messages = Array.from({ length: 10 }, (_, i) => makeMessage({ id: i + 1 }));
setMessages(1, messages);
msgList.mount(container);
// Installed after mount() so it only observes what scrollToMessage does,
// not mount's own (single, expected) abort registration.
const addEventListenerSpy = vi.spyOn(AbortSignal.prototype, "addEventListener");
for (let i = 1; i <= 5; i++) {
expect(msgList.scrollToMessage(i)).toBe(true);
}
// Each jump's highlight-flash cleanup must not add a new listener to the
// whole-lifetime AbortSignal — that accumulates one listener (and pins
// one detached row element through its closure) per jump, released only
// when the channel unmounts, not when that jump's flash finishes.
const abortRegistrations = addEventListenerSpy.mock.calls.filter(([type]) => type === "abort");
expect(abortRegistrations.length).toBe(0);
addEventListenerSpy.mockRestore();
});
it("rebuilds the virtual window when scrolling outside the rendered range", async () => {
setHasMore(1, false);
const many = Array.from({ length: 300 }, (_, i) => makeMessage({ id: i + 1 }));
@@ -4,6 +4,8 @@ import { authStore } from "../../src/stores/auth.store";
import { channelsStore } from "../../src/stores/channels.store";
import { dmStore } from "../../src/stores/dm.store";
import type { DmChannel } from "../../src/stores/dm.store";
import { membersStore } from "../../src/stores/members.store";
import { messagesStore } from "../../src/stores/messages.store";
import type { ChatMessagePayload } from "../../src/lib/types";
// vi.hoisted ensures testPrefs is available when vi.mock factory runs
@@ -143,6 +145,19 @@ describe("notifyIncomingMessage", () => {
// into another that expects the plain channelsStore fallback.
dmStore.setState(() => ({ channels: [] }));
// Reset the member store so a nickname seeded by one test cannot leak
// into another that expects the plain-username title.
membersStore.setState(() => ({
members: new Map(),
typingUsers: new Map(),
roleRevision: 0,
}));
// A channel marked detached by one test (viewing a back-history
// around-window) must not leak into another that expects the plain
// active-channel suppression.
messagesStore.setState((prev) => ({ ...prev, detachedChannels: new Set() }));
// Ensure document.hasFocus returns false (simulating unfocused window)
vi.spyOn(document, "hasFocus").mockReturnValue(false);
});
@@ -719,6 +734,76 @@ describe("notifyIncomingMessage", () => {
});
});
// OC-0233: the popup that tells you who wrote to you has to name them the
// same way the message row you click through to does. resolveAuthor
// (message-list/formatting.ts) prefers the live membersStore copy of the
// author's nickname over whatever was frozen into the payload.
it("titles the notification with the member store's nickname, not the raw username", async () => {
const { sendNotification } = await import("@tauri-apps/plugin-notification");
(sendNotification as ReturnType<typeof vi.fn>).mockClear();
membersStore.setState(() => ({
members: new Map([
[
2,
{
id: 2,
username: "a_martinez",
avatar: null,
role: "member",
status: "online" as const,
displayName: "Alice",
},
],
]),
typingUsers: new Map(),
roleRevision: 1,
}));
testPrefs.set("desktopNotifications", true);
testPrefs.set("flashTaskbar", false);
testPrefs.set("notificationSounds", false);
const payload = makePayload({
user: { id: 2, username: "a_martinez", avatar: null },
channel_id: 1,
content: "hi",
});
notifyIncomingMessage(payload);
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalledWith({
title: "Alice in #general",
body: "hi",
});
});
});
// Same fix, payload-only path: the author has a display_name on the
// message but is not (yet) in the member store.
it("titles the notification with the payload's display_name when the author is not in the member store", async () => {
const { sendNotification } = await import("@tauri-apps/plugin-notification");
(sendNotification as ReturnType<typeof vi.fn>).mockClear();
testPrefs.set("desktopNotifications", true);
testPrefs.set("flashTaskbar", false);
testPrefs.set("notificationSounds", false);
const payload = makePayload({
user: { id: 2, username: "a_martinez", avatar: null, display_name: "Alice" },
channel_id: 1,
content: "hi",
});
notifyIncomingMessage(payload);
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalledWith({
title: "Alice in #general",
body: "hi",
});
});
});
it("uses fallback channel name with correct channel ID", async () => {
const { sendNotification } = await import("@tauri-apps/plugin-notification");
(sendNotification as ReturnType<typeof vi.fn>).mockClear();
@@ -1100,6 +1185,34 @@ describe("notifyIncomingMessage", () => {
expect(sendNotification).toHaveBeenCalled();
});
});
// OC-0204: "active channel" is not the same thing as "the user is
// watching the live tail". A jump to an old permalink/reply/search hit
// in the active channel opens a detached around-window (messages.store's
// detachedChannels) — addMessage refuses to append a live broadcast onto
// it, and dispatcher.ts skips the unread bump because the channel is
// "active". If this guard also suppresses the notification, an @mention
// that arrives while the user reads back-history reaches them through
// literally nothing — not even a popup — even though the window is
// focused and they are looking at #general.
it("proceeds when window focused AND channel matches BUT the window is detached (reading back-history)", async () => {
const { sendNotification } = await import("@tauri-apps/plugin-notification");
(sendNotification as ReturnType<typeof vi.fn>).mockClear();
vi.spyOn(document, "hasFocus").mockReturnValue(true);
channelsStore.setState((prev) => ({ ...prev, activeChannelId: 1 }));
messagesStore.setState((prev) => ({ ...prev, detachedChannels: new Set([1]) }));
testPrefs.set("desktopNotifications", true);
testPrefs.set("flashTaskbar", false);
testPrefs.set("notificationSounds", false);
notifyIncomingMessage(makePayload({ channel_id: 1 }));
await vi.waitFor(() => {
expect(sendNotification).toHaveBeenCalled();
});
});
});
describe("notification toggles independently control each action", () => {
@@ -0,0 +1,152 @@
// Pins OC-0206: AudioWorkletProcessor.process() runs once per 128-sample
// render quantum (2.667ms at the 48kHz AudioContext AudioPipeline creates),
// not once per ~16ms poll like the setTimeout fallback. vad-worklet.js's gate
// timing constants were copy-pasted from the fallback's 16ms-poll frame
// counts, so on the worklet path the mic gate closes ~6x faster than
// intended (~32ms of silence instead of ~200ms), and the other timing
// constants are off by the same factor.
//
// This loads the actual public/vad-worklet.js source (not a reimplementation)
// into a small VM sandbox that stands in for the AudioWorkletGlobalScope, so
// it exercises the real VadProcessor class.
import { describe, it, expect, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import vm from "node:vm";
const __dirname = dirname(fileURLToPath(import.meta.url));
const WORKLET_PATH = resolve(__dirname, "../../public/vad-worklet.js");
// One render quantum at the 48kHz AudioContext AudioPipeline creates
// (audioPipeline.ts: `new AudioContext({ sampleRate: 48000 })`).
const FRAME_MS = (128 / 48000) * 1000; // ≈ 2.667ms
function loadVadProcessor(): new () => any {
const code = readFileSync(WORKLET_PATH, "utf-8");
const registered: Record<string, new () => any> = {};
class AudioWorkletProcessor {
port: { onmessage: ((event: unknown) => void) | null; postMessage: (msg: unknown) => void };
constructor() {
this.port = { onmessage: null, postMessage: () => {} };
}
}
const sandbox: Record<string, unknown> = {
AudioWorkletProcessor,
registerProcessor: (name: string, cls: new () => unknown) => {
registered[name] = cls as new () => any;
},
};
vm.createContext(sandbox);
vm.runInContext(code, sandbox, { filename: "vad-worklet.js" });
const ctor = registered["vad-processor"];
if (ctor === undefined) {
throw new Error('vad-worklet.js did not registerProcessor("vad-processor")');
}
return ctor;
}
function frame(value: number, length = 128): Float32Array {
return new Float32Array(length).fill(value);
}
function postMessageMock(proc: any): ReturnType<typeof vi.fn> {
return proc.port.postMessage as ReturnType<typeof vi.fn>;
}
/** Repeatedly calls process() with a constant-level input frame until
* port.postMessage receives a message of `type`, returning the number of
* process() calls that took (the call that produced the message counts). */
function callsUntilMessageOfType(
proc: any,
sampleValue: number,
type: string,
maxCalls: number,
): number {
for (let i = 1; i <= maxCalls; i++) {
const before = postMessageMock(proc).mock.calls.length;
proc.process([[frame(sampleValue)]]);
const calls = postMessageMock(proc).mock.calls;
for (let j = before; j < calls.length; j++) {
const call = calls[j];
if (call === undefined) continue;
if ((call[0] as { type: string }).type === type) return i;
}
}
throw new Error(`no "${type}" message within ${maxCalls} process() calls`);
}
describe("vad-worklet.js VadProcessor timing (128-sample render quanta @48kHz)", () => {
const SILENT = 0; // rms 0, below default threshold 0.05
const LOUD = 0.5; // rms 0.5, above default threshold 0.05
function freshUngatedProcessor(): any {
const VadProcessor = loadVadProcessor();
const proc = new VadProcessor();
proc.port.postMessage = vi.fn();
// Fast-forward well past the startup grace period with loud (non-gating)
// audio. Starting ungated, loud audio never posts a "gate" message, so
// this is safe regardless of how long the grace period actually is.
for (let i = 0; i < 300; i++) proc.process([[frame(LOUD)]]);
return proc;
}
it("does not close the gate until ~200ms of silence (≈75 render quanta), not ~32ms (12 quanta)", () => {
const proc = freshUngatedProcessor();
const calls = callsUntilMessageOfType(proc, SILENT, "gate", 200);
const elapsedMs = calls * FRAME_MS;
// 12 quanta (the current, wrong constant) is ~32ms — well under 150ms.
// 75 quanta (~200ms) is the intended timing.
expect(elapsedMs).toBeGreaterThan(150);
expect(elapsedMs).toBeLessThan(260);
});
it("does not reopen the gate until ~32ms of speech (≈12 render quanta), not ~5ms (2 quanta)", () => {
const proc = freshUngatedProcessor();
// Drive it into the gated state first.
callsUntilMessageOfType(proc, SILENT, "gate", 200);
postMessageMock(proc).mockClear();
const calls = callsUntilMessageOfType(proc, LOUD, "gate", 200);
const elapsedMs = calls * FRAME_MS;
// 2 quanta (current) is ~5.3ms. 12 quanta (~32ms, matching the
// setTimeout fallback's GATE_OFF_FRAMES=2 @ 16ms poll) is intended.
expect(elapsedMs).toBeGreaterThan(20);
expect(elapsedMs).toBeLessThan(45);
});
it("suppresses all messages for close to 500ms of startup grace (≈188 quanta), not ~80ms (30 quanta)", () => {
const VadProcessor = loadVadProcessor();
const proc = new VadProcessor();
proc.port.postMessage = vi.fn();
// 160 quanta ≈ 427ms: comfortably past the current, wrong 30-quantum
// (~80ms) grace plus the current 12-quantum gate-on delay, but still
// short of the intended ~500ms grace.
for (let i = 0; i < 160; i++) proc.process([[frame(SILENT)]]);
expect(postMessageMock(proc)).not.toHaveBeenCalled();
});
it("posts the RMS indicator roughly every ~50ms (≈19 quanta) once past startup, not every ~16ms (6 quanta)", () => {
const proc = freshUngatedProcessor();
// Discard the first (possibly phase-shifted) interval, then measure a
// full period: the counter resets to 0 immediately after each post.
callsUntilMessageOfType(proc, LOUD, "rms", 300);
postMessageMock(proc).mockClear();
const period = callsUntilMessageOfType(proc, LOUD, "rms", 100);
const periodMs = period * FRAME_MS;
// 6 quanta (current) is ~16ms. 19 quanta (~50ms) is intended.
expect(periodMs).toBeGreaterThan(35);
expect(periodMs).toBeLessThan(65);
});
});
@@ -186,6 +186,27 @@ describe("createVoiceWidgetCallbacks", () => {
expect(mockSetMuted).not.toHaveBeenCalled();
});
it("does not send voice_deafen{deafened:false} on unmute while server-deafened (OC-0216)", () => {
// Mirrors onDeafenToggle's localServerMuted guard (OC-0179): a
// moderator-imposed deafen is not ours to lift, so unmuting must not
// spend a doomed voice_deafen round-trip that the server will refuse
// with SERVER_DEAFENED.
mockVoiceStoreGetState.mockReturnValue(
makeVoiceState({ localMuted: true, localDeafened: true, localServerDeafened: true }),
);
const ws = makeWs();
const cbs = createVoiceWidgetCallbacks(ws, makeLimiters());
cbs.onMuteToggle();
// The unmute itself still goes through...
expect(mockSetMuted).toHaveBeenCalledWith(false);
expect(ws.send).toHaveBeenCalledWith({ type: "voice_mute", payload: { muted: false } });
// ...but the undeafen must be suppressed while the server deafen stands.
expect(mockSetDeafened).not.toHaveBeenCalled();
expect(ws.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: "voice_deafen" }));
});
});
describe("onDeafenToggle", () => {
+50 -2
View File
@@ -138,7 +138,17 @@ func handlePutChannelPermission(database *db.DB, hub HubBroadcaster, permInvalid
}
// Escalation guard: a MANAGE_CHANNELS holder without ADMINISTRATOR
// cannot grant bits their own role lacks via a channel override.
if err := requireGrantableOverride(actorRole, allow, deny); err != nil {
// Checked against the union of the bits being written and the bits
// already present on the row: clearing an existing deny is also a
// grant (EffectivePerms = (rolePerm &^ deny) | allow), so writing an
// all-zero mask over a deny the actor's own role lacks must not slip
// past this guard just because the NEW mask alone is empty.
curAllow, curDeny, err := database.GetChannelPermissions(r.Context(), ch.ID, roleID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel permission")
return
}
if err := requireGrantableOverride(actorRole, curAllow|allow, curDeny|deny); err != nil {
writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error())
return
}
@@ -215,6 +225,21 @@ func handleDeleteChannelPermission(database *db.DB, hub HubBroadcaster, permInva
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated")
return
}
// Escalation guard: deleting an override is a permission mutation with
// the same authority as writing one — removing a deny row restores
// exactly the access the PUT path refuses to grant (EffectivePerms =
// (rolePerm &^ deny) | allow) — so gate it identically to
// handlePutChannelPermission, checked against the bits the deleted row
// actually carries.
curAllow, curDeny, err := database.GetChannelPermissions(r.Context(), ch.ID, roleID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel permission")
return
}
if err := requireGrantableOverride(actorRole, curAllow, curDeny); err != nil {
writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error())
return
}
// Hierarchy guard: deleting an override is a permission mutation with the
// same authority as writing one (removing a deny row restores exactly the
// access the PUT path refuses to grant), so gate it identically to
@@ -339,7 +364,16 @@ func handlePutChannelUserPermission(database *db.DB, hub HubBroadcaster, permInv
}
// Escalation guard: a MANAGE_CHANNELS holder without ADMINISTRATOR
// cannot grant bits their own role lacks via a per-user override.
if err := requireGrantableOverride(actorRole, allow, deny); err != nil {
// Checked against the union of the bits being written and the bits
// already present on the row, same rationale as
// handlePutChannelPermission: clearing an existing deny is a grant, so
// an all-zero write must not bypass this guard.
curAllow, curDeny, err := database.GetUserChannelPermissions(r.Context(), ch.ID, user.ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel user permission")
return
}
if err := requireGrantableOverride(actorRole, curAllow|allow, curDeny|deny); err != nil {
writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error())
return
}
@@ -392,6 +426,20 @@ func handleDeleteChannelUserPermission(database *db.DB, hub HubBroadcaster, perm
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "not authenticated")
return
}
// Escalation guard: clearing a per-user override restores exactly the
// access the PUT path refuses to grant (EffectivePerms = (rolePerm &^
// deny) | allow), so gate it identically to
// handlePutChannelUserPermission, checked against the bits the
// deleted row actually carries.
curAllow, curDeny, err := database.GetUserChannelPermissions(r.Context(), ch.ID, user.ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch channel user permission")
return
}
if err := requireGrantableOverride(actorRole, curAllow, curDeny); err != nil {
writeErr(w, http.StatusForbidden, "FORBIDDEN", err.Error())
return
}
// Hierarchy guard: clearing a higher-ranked member's override is the
// same authority as writing one, so gate it identically.
if !requireManageableUser(database, w, r, user, actorRole) {
@@ -431,3 +431,91 @@ func TestDeleteChannelPermission_UnknownRole(t *testing.T) {
t.Errorf("status = %d, want 404; body: %s", w.Code, w.Body.String())
}
}
// Clearing an override is a permission grant when it removes a deny bit the
// actor's own role does not hold: EffectivePerms = (rolePerm &^ deny) | allow,
// so wiping a deny row hands back exactly the access the PUT path refuses to
// grant (TestPutChannelPermission_ModeratorCannotEscalate). The DELETE
// handler must apply requireGrantableOverride to the override being REMOVED,
// not skip it just because the hierarchy guard alone passes.
func TestDeleteChannelPermission_EscalationGuard(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// Helper role: low position, base permissions include MANAGE_MESSAGES.
if _, err := database.ExecContext(context.Background(),
`INSERT INTO roles (id, name, color, permissions, position, is_default)
VALUES (20, 'Helper', NULL, ?, 5, 0)`,
permissions.ManageMessages,
); err != nil {
t.Fatalf("seed Helper role: %v", err)
}
// Actor: MANAGE_CHANNELS holder without MANAGE_MESSAGES or ADMINISTRATOR,
// ranked above Helper so only the escalation guard is exercised.
_, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser")
chID, err := database.CreateChannel(context.Background(), "escalate-del", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
if err := database.UpsertChannelOverride(context.Background(), chID, 20, 0, permissions.ManageMessages); err != nil {
t.Fatalf("UpsertChannelOverride: %v", err)
}
w := doRequest(t, handler, http.MethodDelete,
"/channels/"+itoa(chID)+"/permissions/20", modToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String())
}
allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 20)
if err != nil {
t.Fatalf("GetChannelPermissions: %v", err)
}
if allow != 0 || deny != permissions.ManageMessages {
t.Errorf("override mutated by forbidden delete: (%#x, %#x)", allow, deny)
}
}
// A PUT with an all-zero mask that clears an existing deny bit the actor's
// own role does not hold is exactly as much an escalation as writing that
// bit directly (TestPutChannelPermission_ModeratorCannotEscalate): clearing a
// deny is a grant. requireGrantableOverride must see the bits being REMOVED
// by this write, not just the (trivially empty) bits being written.
func TestPutChannelPermission_ClearByZeroMaskEscalationGuard(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
if _, err := database.ExecContext(context.Background(),
`INSERT INTO roles (id, name, color, permissions, position, is_default)
VALUES (20, 'Helper', NULL, ?, 5, 0)`,
permissions.ManageMessages,
); err != nil {
t.Fatalf("seed Helper role: %v", err)
}
_, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser")
chID, err := database.CreateChannel(context.Background(), "escalate-zero", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
if err := database.UpsertChannelOverride(context.Background(), chID, 20, 0, permissions.ManageMessages); err != nil {
t.Fatalf("UpsertChannelOverride: %v", err)
}
w := doRequest(t, handler, http.MethodPut,
"/channels/"+itoa(chID)+"/permissions/20", modToken,
map[string]any{"allow": 0, "deny": 0})
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String())
}
allow, deny, err := database.GetChannelPermissions(context.Background(), chID, 20)
if err != nil {
t.Fatalf("GetChannelPermissions: %v", err)
}
if allow != 0 || deny != permissions.ManageMessages {
t.Errorf("override mutated by forbidden zero-mask PUT: (%#x, %#x)", allow, deny)
}
}
@@ -382,3 +382,72 @@ func TestDeleteChannelUserPermission_ClearsOverride(t *testing.T) {
t.Errorf("second delete status = %d, want 204", w.Code)
}
}
// Clearing a per-user override is a permission grant when it removes a deny
// bit the actor's own role does not hold, exactly like the role-layer case
// (TestDeleteChannelPermission_EscalationGuard in handlers_channel_perms_test.go).
// The DELETE handler must apply requireGrantableOverride to the override
// being REMOVED, not skip the escalation guard because hierarchy alone
// passes.
func TestDeleteChannelUserPermission_EscalationGuard(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
// Actor: MANAGE_CHANNELS holder without MANAGE_MESSAGES or ADMINISTRATOR.
_, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser")
target := seedOverrideTarget(t, database, "escalate-del-target")
chID, err := database.CreateChannel(context.Background(), "escalate-del-user", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
if err := database.UpsertChannelUserOverride(context.Background(), chID, target, 0, permissions.ManageMessages); err != nil {
t.Fatalf("UpsertChannelUserOverride: %v", err)
}
w := doRequest(t, handler, http.MethodDelete,
"/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), modToken, nil)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String())
}
allow, deny, err := database.GetUserChannelPermissions(context.Background(), chID, target)
if err != nil {
t.Fatalf("GetUserChannelPermissions: %v", err)
}
if allow != 0 || deny != permissions.ManageMessages {
t.Errorf("override mutated by forbidden delete: (%#x, %#x)", allow, deny)
}
}
// Same escalation, reached through a PUT that writes an all-zero mask: it
// still clears the existing deny bit, which is a grant
// (TestPutChannelPermission_ClearByZeroMaskEscalationGuard's per-user twin).
func TestPutChannelUserPermission_ClearByZeroMaskEscalationGuard(t *testing.T) {
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
_, modToken := createRoleUser(t, database, 10, "Moderator", permissions.ManageChannels, 70, "moduser")
target := seedOverrideTarget(t, database, "escalate-zero-target")
chID, err := database.CreateChannel(context.Background(), "escalate-zero-user", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
if err := database.UpsertChannelUserOverride(context.Background(), chID, target, 0, permissions.ManageMessages); err != nil {
t.Fatalf("UpsertChannelUserOverride: %v", err)
}
w := doRequest(t, handler, http.MethodPut,
"/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), modToken,
map[string]any{"allow": 0, "deny": 0})
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", w.Code, w.Body.String())
}
allow, deny, err := database.GetUserChannelPermissions(context.Background(), chID, target)
if err != nil {
t.Fatalf("GetUserChannelPermissions: %v", err)
}
if allow != 0 || deny != permissions.ManageMessages {
t.Errorf("override mutated by forbidden zero-mask PUT: (%#x, %#x)", allow, deny)
}
}
+12 -2
View File
@@ -3,6 +3,7 @@ package admin
import (
"context"
"errors"
"log/slog"
"net/http"
"github.com/owncord/server/auth"
@@ -53,9 +54,18 @@ func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler {
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "user not found")
case errors.Is(err, auth.ErrRoleNotFound):
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "role not found")
default:
// ErrTokenNotFound or a wrapped DB error.
case errors.Is(err, auth.ErrTokenNotFound):
writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session")
default:
// A wrapped DB error, not one of the sentinels above (mirrors
// api.AuthMiddleware). A DB outage is not a bad token:
// answering 401 here would make the client treat a live,
// valid session as expired — the desktop client's doFetch
// 401 sink clears auth and deletes the stored credential for
// a session that was never revoked. Log it and report the
// failure as a server-side fault instead.
slog.ErrorContext(r.Context(), "admin: token resolution failed", "error", err)
writeErr(w, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "authentication service temporarily unavailable")
}
return
}
+66
View File
@@ -0,0 +1,66 @@
// Package admin whitebox test for OC-0225: adminAuthMiddleware must not
// report a transient DB error from auth.ResolveTokenHash as 401. A wrapped
// DB error is not "invalid or expired session" — treating it as one ejects
// an admin whose session was never revoked (see the finding for the desktop
// client's onUnauthorized -> clearAuth -> deleteCredential chain triggered by
// a stray 401).
package admin
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/owncord/server/auth"
)
// TestAdminAuthMiddleware_DBErrorIsNotUnauthorized verifies that when
// ResolveTokenHash fails with a wrapped (non-sentinel) DB error — as happens
// when the underlying SQLite connection is unavailable — adminAuthMiddleware
// reports 503 SERVICE_UNAVAILABLE, not 401 UNAUTHORIZED. A 401 here is
// indistinguishable from a genuinely dead/unknown session and drives the
// desktop client to clear auth and delete the stored credential for a
// session that was never actually revoked.
func TestAdminAuthMiddleware_DBErrorIsNotUnauthorized(t *testing.T) {
database := openWhiteboxTestDB(t)
uid, err := database.CreateUser(context.Background(), "dberroruser", "$2a$12$x", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token := "db-error-token"
if _, err := database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
// Close the DB so the very next query — GetSessionByTokenHash, called
// from inside ResolveTokenHash — fails with a wrapped, non-sentinel
// error (not sql.ErrNoRows, so not ErrTokenNotFound either).
if err := database.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
handler := NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/stats", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code == http.StatusUnauthorized {
t.Fatalf("status = %d (UNAUTHORIZED), want 503 (SERVICE_UNAVAILABLE) for a transient DB error; body: %s", w.Code, w.Body.String())
}
if w.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503 (SERVICE_UNAVAILABLE); body: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp["error"] == "UNAUTHORIZED" {
t.Errorf("error = %q, must not be UNAUTHORIZED for a DB outage", resp["error"])
}
}
+25 -4
View File
@@ -72,7 +72,7 @@ var _ dmVoiceEvictor = (*ws.Hub)(nil)
func MountDMRoutes(r chi.Router, database *db.DB, svc *service.Services, broadcaster DMBroadcaster) {
r.Route("/api/v1/dms", func(r chi.Router) {
r.Use(AuthMiddleware(database))
r.Post("/", handleCreateDM(svc))
r.Post("/", handleCreateDM(svc, broadcaster))
r.Post("/group", handleCreateGroupDM(svc, broadcaster))
r.Get("/", handleListDMs(svc))
r.Patch("/{channelId}", handleRenameGroupDM(svc, broadcaster))
@@ -117,7 +117,7 @@ type listDMsResponse struct {
}
// handleCreateDM creates or retrieves a DM channel with a recipient.
func handleCreateDM(svc *service.Services) http.HandlerFunc {
func handleCreateDM(svc *service.Services, broadcaster DMBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
@@ -141,6 +141,19 @@ func handleCreateDM(svc *service.Services) http.HandlerFunc {
return
}
// A brand-new 1:1 DM has dm_open_state pre-seeded for BOTH users by
// GetOrCreateDMChannel (db/dm_queries.go), so the recipient's first
// OpenDM call — fired later from the sender's first message — finds
// the row already present and reports opened=false. Without this,
// nothing ever tells the recipient the DM exists: no live event, and
// no visibility-watermark bump for a warm reconnect either. Only the
// creation path needs this — CreateDM re-opening an existing DM for
// the caller only touches the caller's own dm_open_state row, which
// the caller obviously already knows about.
if result.Created {
broadcastDMOpen(r.Context(), svc, broadcaster, result.Channel.ID, []int64{result.Recipient.ID})
}
avatarStr := ""
if result.Recipient.Avatar != nil {
avatarStr = *result.Recipient.Avatar
@@ -399,6 +412,14 @@ func handleBlockUser(svc *service.Services, broadcaster DMBroadcaster) http.Hand
return
}
// The block has already committed at this point, so the rest of this
// handler must survive the caller's request context being cancelled
// right after that commit (client disconnect mid-handler) — same
// reasoning as handleRenameGroupDM's own bgCtx. Without this, a
// canceled request context makes the shared-DM lookup below fail and
// get skipped, silently defeating the eviction it gates.
bgCtx := context.WithoutCancel(r.Context())
// Revocation must evict a live session, not merely block the next
// join (the same invariant the voice sweep states): without this, a
// blocked user already in the pair's 1:1 DM voice call stays in it
@@ -407,11 +428,11 @@ func handleBlockUser(svc *service.Services, broadcaster DMBroadcaster) http.Hand
// controls. Group DM calls are deliberately untouched, matching
// requireDMNotBlocked's group exemption.
if ve, evictable := broadcaster.(dmVoiceEvictor); evictable {
if chID, exists, err := svc.DMs.SharedOneToOneDM(r.Context(), user.ID, targetID); err != nil {
if chID, exists, err := svc.DMs.SharedOneToOneDM(bgCtx, user.ID, targetID); err != nil {
slog.Warn("block: shared-DM lookup for voice eviction failed",
"blocker_id", user.ID, "target_id", targetID, "err", err)
} else if exists {
ve.DisconnectFromVoiceInChannel(context.WithoutCancel(r.Context()), targetID, chID)
ve.DisconnectFromVoiceInChannel(bgCtx, targetID, chID)
}
}
@@ -0,0 +1,88 @@
package api_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/api"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
"github.com/owncord/server/service"
)
// cancelAfterBlockStore wraps a real in-memory *db.DB and cancels an
// externally supplied context the instant BlockUser's write commits,
// simulating a client disconnect landing between the block commit and the
// post-commit voice-eviction gate (OC-0198). FindDMChannelIDBetween is
// overridden to fail fast on an already-canceled context, mirroring the
// context.Canceled a real sql query would surface in that window.
type cancelAfterBlockStore struct {
*db.DB
cancel context.CancelFunc
}
func (s *cancelAfterBlockStore) BlockUser(ctx context.Context, blockerID, blockedID int64) error {
err := s.DB.BlockUser(ctx, blockerID, blockedID)
if err == nil {
s.cancel()
}
return err
}
func (s *cancelAfterBlockStore) FindDMChannelIDBetween(ctx context.Context, user1ID, user2ID int64) (int64, bool, error) {
if err := ctx.Err(); err != nil {
return 0, false, err
}
return s.DB.FindDMChannelIDBetween(ctx, user1ID, user2ID)
}
// TestBlockUser_EvictsVoiceEvenIfRequestContextCanceledAfterCommit pins
// OC-0198: BlockUser has already committed once the store call returns, so a
// client disconnect that cancels the request context right after must not
// suppress the post-commit voice eviction. The shared-DM lookup gating that
// eviction has to run on a context detached from the request — the same way
// the eviction call itself already does — or the blocked user stays in the
// blocker's live 1:1 DM call forever.
func TestBlockUser_EvictsVoiceEvenIfRequestContextCanceledAfterCommit(t *testing.T) {
database := newDMTestDB(t)
bc := &watermarkVoiceBroadcaster{mockBroadcaster: &mockBroadcaster{}}
alice := dmCreateToken(t, database, "alice", 4)
dmCreateToken(t, database, "bob", 4)
setupRouter := buildDMRouter(database, bc)
rr := dmPost(t, setupRouter, "/api/v1/dms", alice, map[string]any{"recipient_id": 2})
var created struct {
ChannelID int64 `json:"channel_id"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create-dm response %q: %v", rr.Body.String(), err)
}
bc.evictCalls = nil
ctx, cancel := context.WithCancel(context.Background())
store := &cancelAfterBlockStore{DB: database, cancel: cancel}
svc := service.New(store, auth.NewRateLimiter())
r := chi.NewRouter()
api.MountDMRoutes(r, database, svc, bc)
req := httptest.NewRequest(http.MethodPut, "/api/v1/blocks/2", nil)
req.Header.Set("Authorization", "Bearer "+alice)
req.RemoteAddr = "127.0.0.1:9999"
req = req.WithContext(ctx)
blockRR := httptest.NewRecorder()
r.ServeHTTP(blockRR, req)
if blockRR.Code != http.StatusOK {
t.Fatalf("block: %d %s", blockRR.Code, blockRR.Body.String())
}
if len(bc.evictCalls) != 1 || bc.evictCalls[0].userID != 2 || bc.evictCalls[0].channelID != created.ChannelID {
t.Fatalf("DisconnectFromVoiceInChannel calls = %+v, want exactly one for user=2 channel=%d even though "+
"the request context was canceled right after the block commit", bc.evictCalls, created.ChannelID)
}
}
@@ -0,0 +1,72 @@
package api_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"
)
// TestCreateDM_Success_NotifiesRecipient pins OC-0199: a REST-created 1:1 DM
// must tell the recipient about it immediately (a dm_channel_open event),
// mirroring what handleCreateGroupDM already does via broadcastDMOpen.
//
// Without this, GetOrCreateDMChannel pre-opens dm_open_state for BOTH users
// at creation time, so the recipient's OpenDM call on the first message
// later finds the row already present (INSERT OR IGNORE affects 0 rows) and
// never reports "opened" either — leaving the recipient with no live event
// and no visibility-watermark bump to pick the DM up on a warm reconnect.
func TestCreateDM_Success_NotifiesRecipient(t *testing.T) {
database := newDMTestDB(t)
broadcaster := &mockBroadcaster{}
router := buildDMRouter(database, broadcaster)
tokenAlice := dmCreateToken(t, database, "notify_alice", 4)
_ = dmCreateToken(t, database, "notify_bob", 4)
bob, err := database.GetUserByUsername(context.Background(), "notify_bob")
if err != nil || bob == nil {
t.Fatalf("lookup bob: %v", err)
}
rr := dmPost(t, router, "/api/v1/dms", tokenAlice, map[string]any{
"recipient_id": bob.ID,
})
if rr.Code != http.StatusCreated {
t.Fatalf("CreateDM: status = %d, want 201; body = %s", rr.Code, rr.Body.String())
}
var gotOpenForBob bool
for _, m := range broadcaster.sent {
if m.UserID != bob.ID {
continue
}
var payload struct {
Type string `json:"type"`
}
if jsonErr := json.Unmarshal(m.Msg, &payload); jsonErr != nil {
continue
}
if payload.Type == "dm_channel_open" {
gotOpenForBob = true
}
}
if !gotOpenForBob {
t.Errorf("CreateDM: recipient %d never got a dm_channel_open broadcast; sent = %v",
bob.ID, dumpSent(broadcaster.sent))
}
}
func dumpSent(sent []mockBroadcastMsg) string {
var b bytes.Buffer
for _, m := range sent {
b.WriteString(m.String())
b.WriteByte('\n')
}
return b.String()
}
// String renders a mockBroadcastMsg for test failure output.
func (m mockBroadcastMsg) String() string {
return string(m.Msg)
}
+16
View File
@@ -67,6 +67,22 @@ CREATE TABLE IF NOT EXISTS sessions (
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
-- AuthMiddleware falls through to an API-token lookup whenever a bearer
-- token matches no session (auth.ResolveTokenHash), so this table must exist
-- even in DM-only fixtures otherwise an ordinary "no such session" lookup
-- for a garbage/unknown token hits GetActiveAPIToken and fails with a real
-- "no such table" SQL error instead of the intended not-found sentinel.
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
label TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT,
expires_at TEXT,
revoked_at TEXT
);
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
+13 -6
View File
@@ -114,17 +114,24 @@ func AuthMiddleware(database *db.DB) func(http.Handler) http.Handler {
Message: "role not found",
})
return
case err != nil:
// ErrTokenNotFound or a wrapped DB error. A DB outage is not a bad
// token — log it so it's distinguishable from ordinary 401s.
if !errors.Is(err, auth.ErrTokenNotFound) {
slog.ErrorContext(r.Context(), "auth: token resolution failed", "error", err)
}
case errors.Is(err, auth.ErrTokenNotFound):
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "invalid or expired session",
})
return
case err != nil:
// A wrapped DB error, not one of the sentinels above. A DB outage
// is not a bad token: answering 401 here would make the client
// treat a live, valid session as expired — it clears auth,
// disconnects the WS, and deletes the stored credential. Log it
// and report the failure as a server-side fault instead.
slog.ErrorContext(r.Context(), "auth: token resolution failed", "error", err)
writeJSON(w, http.StatusServiceUnavailable, errorResponse{
Error: "SERVICE_UNAVAILABLE",
Message: "authentication service temporarily unavailable",
})
return
}
// Reject effectively-banned users before any further processing.
+37
View File
@@ -299,6 +299,43 @@ func TestAuthMiddleware_DanglingRoleUnauthorized(t *testing.T) {
}
}
// TestAuthMiddleware_DBErrorIsNotUnauthorized pins OC-0202: a transient DB
// read error while resolving the bearer token (auth.ResolveTokenHash returns
// it WRAPPED, never as a sentinel) must not be reported as 401 UNAUTHORIZED.
// The desktop client treats every 401 as "session expired": it clears auth,
// disconnects the WS, and deletes the stored OS-keyring credential. A DB
// outage is not a bad token, so it must surface as a server-side failure
// (503) instead of tearing down a perfectly valid session.
func TestAuthMiddleware_DBErrorIsNotUnauthorized(t *testing.T) {
database := newAPITestDB(t)
uid, _ := database.CreateUser(context.Background(), "erin", "hash", 4)
token, _ := auth.GenerateToken()
hash := auth.HashToken(token)
_, _ = database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1")
h := api.AuthMiddleware(database)(http.HandlerFunc(ok))
// Close the underlying DB so the next GetSessionByTokenHash call fails
// with a wrapped "database is closed" error rather than sql.ErrNoRows —
// standing in for a transient outage (locked DB, disk I/O error, a
// restore swapping the file underneath the running server).
if err := database.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
req := withBearer(httptest.NewRequest(http.MethodGet, "/", nil), token)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
if rr.Code == http.StatusUnauthorized {
t.Errorf("AuthMiddleware DB error status = %d, want non-401 (503)", rr.Code)
}
if rr.Code != http.StatusServiceUnavailable {
t.Errorf("AuthMiddleware DB error status = %d, want 503", rr.Code)
}
}
// ─── RequirePermission tests ──────────────────────────────────────────────────
func TestRequirePermission_Allowed(t *testing.T) {
+131 -84
View File
@@ -31,8 +31,10 @@ type updateProfileRequest struct {
Avatar *string `json:"avatar"`
IdentityPublicKey *string `json:"identity_public_key"`
// DisplayName and About are omitted = unchanged, "" = cleared. Both are
// sanitized and length-checked in UserService, which is also the path a
// non-REST caller would take.
// length-checked in UserService, which is also the path a non-REST
// caller would take; DisplayName is additionally sanitized in this
// handler (before validateDisplayName runs — see the OC-0197 comment at
// the call site) and UserService's own sanitize of it is then a no-op.
DisplayName *string `json:"display_name"`
About *string `json:"about"`
}
@@ -158,6 +160,131 @@ var allowedAvatarMIME = map[string]bool{
// ─── Handlers ────────────────────────────────────────────────────────────────
// handleUpdateProfile processes PATCH /api/v1/users/me.
// parseUpdateProfileRequest decodes the PATCH /users/me body and applies the
// bound-then-sanitize-then-validate pass to every field, in the same order the
// register path canonicalizes them. On any failure it writes the error response
// and returns ok=false, and the caller must return without writing anything
// further. Split out of handleUpdateProfile only to keep that handler under the
// funlen limit; the field logic is unchanged.
func parseUpdateProfileRequest(w http.ResponseWriter, r *http.Request) (updateProfileRequest, bool) {
var req updateProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: "malformed request body",
})
return req, false
}
// OC-0151: bound the raw field before it ever reaches the fixpoint
// sanitizer below, for the same reason as the register path
// (auth_handler.go's registerReadRequest) — sanitizeToFixpoint's
// cost is quadratic in input length, and nothing bounds this field
// before it runs. This is a cheap byte-length pre-check — *4 still
// admits any legitimate 32-rune UTF-8 username.
if len(req.Username) > maxLoginUsernameLen*4 {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: "username is too long",
})
return req, false
}
// Use the fixpoint sanitizer (service.SanitizeText), not a bare
// bluemonday.StrictPolicy().Sanitize call — Sanitize's output is always
// HTML-escaped, so a plain apostrophe would be persisted as &#39;
// and login (which never re-escapes) would look the account up
// under a name that no longer matches. See service.SanitizeText's
// doc comment and the register path (auth_handler.go), which
// already canonicalizes the same way.
req.Username = strings.TrimSpace(service.SanitizeText(req.Username))
if req.Username == "" {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: "username is required",
})
return req, false
}
if err := auth.ValidateUsername(req.Username); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: err.Error(),
})
return req, false
}
// OC-0192: bound the raw field before it reaches the fixpoint
// sanitizer below, same reasoning as the username bound above —
// sanitizeToFixpoint's cost is quadratic in input length. Unlike
// username, an oversized avatar was previously only caught *after*
// sanitizing, by validateAvatarURL's maxAvatarURLLen check.
if req.Avatar != nil && len(*req.Avatar) > maxAvatarURLLen*4 {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: "avatar URL is too long",
})
return req, false
}
// Sanitize and validate avatar if provided. Use the fixpoint
// sanitizer (service.SanitizeText), not a bare
// bluemonday.StrictPolicy().Sanitize call — Sanitize's output is always HTML-escaped, so a URL with more
// than one query parameter would have its "&" separators rewritten
// to "&amp;" and be persisted (and served) broken. Same reasoning as
// the username path above.
if req.Avatar != nil {
trimmed := strings.TrimSpace(service.SanitizeText(*req.Avatar))
if err := validateAvatarURL(trimmed); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: err.Error(),
})
return req, false
}
req.Avatar = &trimmed
}
// display_name gets the same username-shaped scrutiny beyond length:
// it is rendered wherever a username is, so control characters and
// bidi overrides are exactly as unwelcome here. Length and the
// empty-clears-it rule still live in UserService, but sanitizing has
// to happen *before* validateDisplayName, not after: OC-0197 found
// that validating the raw JSON string let an HTML-entity-encoded
// control or bidi character (e.g. "&#x202e;") pass this check as
// harmless ASCII, only to be turned into the real character
// afterwards by UserService.UpdateProfile's cleanText call — the
// same sanitize-then-validate order the username path above already
// uses. OC-0192's raw-byte bound applies here too, now that
// sanitizing happens in this handler (UserService.UpdateProfile
// still bounds DisplayName/About the same way before its own
// cleanText calls, for any non-REST caller; cleanText's fixpoint
// output is stable, so that re-sanitize is a no-op here).
if req.DisplayName != nil {
if len(*req.DisplayName) > service.MaxDisplayNameLen*4 {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: "display_name is too long",
})
return req, false
}
trimmed := strings.TrimSpace(service.SanitizeText(*req.DisplayName))
if err := validateDisplayName(trimmed); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: err.Error(),
})
return req, false
}
req.DisplayName = &trimmed
}
// Validate the identity key before any write so the request is
// all-or-nothing.
if req.IdentityPublicKey != nil {
trimmed := strings.TrimSpace(*req.IdentityPublicKey)
if err := validateIdentityKey(trimmed); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: err.Error(),
})
return req, false
}
req.IdentityPublicKey = &trimmed
}
return req, true
}
func handleUpdateProfile(svc *service.Services, broadcaster ProfileBroadcaster) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
@@ -168,91 +295,11 @@ func handleUpdateProfile(svc *service.Services, broadcaster ProfileBroadcaster)
return
}
var req updateProfileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: "malformed request body",
})
req, ok := parseUpdateProfileRequest(w, r)
if !ok {
return
}
// OC-0151: bound the raw field before it ever reaches the fixpoint
// sanitizer below, for the same reason as the register path
// (auth_handler.go's registerReadRequest) — sanitizeToFixpoint's
// cost is quadratic in input length, and nothing bounds this field
// before it runs. This is a cheap byte-length pre-check — *4 still
// admits any legitimate 32-rune UTF-8 username.
if len(req.Username) > maxLoginUsernameLen*4 {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: "username is too long",
})
return
}
// Use the fixpoint sanitizer (service.SanitizeText), not a bare
// bluemonday.StrictPolicy().Sanitize call — Sanitize's output is always
// HTML-escaped, so a plain apostrophe would be persisted as &#39;
// and login (which never re-escapes) would look the account up
// under a name that no longer matches. See service.SanitizeText's
// doc comment and the register path (auth_handler.go), which
// already canonicalizes the same way.
req.Username = strings.TrimSpace(service.SanitizeText(req.Username))
if req.Username == "" {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: "username is required",
})
return
}
if err := auth.ValidateUsername(req.Username); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: err.Error(),
})
return
}
// Sanitize and validate avatar if provided. Use the fixpoint
// sanitizer (service.SanitizeText), not a bare
// bluemonday.StrictPolicy().Sanitize call — Sanitize's output is always HTML-escaped, so a URL with more
// than one query parameter would have its "&" separators rewritten
// to "&amp;" and be persisted (and served) broken. Same reasoning as
// the username path above.
if req.Avatar != nil {
trimmed := strings.TrimSpace(service.SanitizeText(*req.Avatar))
if err := validateAvatarURL(trimmed); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: err.Error(),
})
return
}
req.Avatar = &trimmed
}
// display_name gets the same username-shaped scrutiny beyond length:
// it is rendered wherever a username is, so control characters and
// bidi overrides are exactly as unwelcome here. Length, sanitization
// and the empty-clears-it rule live in UserService.
if req.DisplayName != nil {
if err := validateDisplayName(*req.DisplayName); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: err.Error(),
})
return
}
}
// Validate the identity key before any write so the request is
// all-or-nothing.
if req.IdentityPublicKey != nil {
trimmed := strings.TrimSpace(*req.IdentityPublicKey)
if err := validateIdentityKey(trimmed); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT", Message: err.Error(),
})
return
}
req.IdentityPublicKey = &trimmed
}
updated, err := svc.Users.UpdateProfile(r.Context(), user.ID, service.ProfilePatch{
Username: req.Username,
Avatar: req.Avatar,
+75
View File
@@ -201,6 +201,81 @@ func TestUpdateProfile_OversizedUsernameRejectedBeforeSanitizing(t *testing.T) {
}
}
// OC-0192: same story as OC-0151 above, but for the avatar field — the
// service.SanitizeText call in the avatar branch has no byte-length guard at
// all, unlike the username field just above it. validateAvatarURL's
// maxAvatarURLLen check never gets a chance to reject a huge payload cheaply,
// because the fixpoint sanitizer already spent its (quadratic) cost on it
// first. The fix must reject an oversized avatar on a cheap byte-length
// check before sanitizing, so the rejection is near-instant regardless of
// payload size.
func TestUpdateProfile_OversizedAvatarRejectedBeforeSanitizing(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "avatarvictim", 4)
// Adversarial nested-entity payload (16 KB) — see service.sanitizeToFixpoint's
// doc comment for why this shape is quadratic to sanitize.
hugeAvatar := "&" + strings.Repeat("amp;", 4000) + "lt;"
start := time.Now()
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
"username": "avatarvictim",
"avatar": hugeAvatar,
})
elapsed := time.Since(start)
if rr.Code != http.StatusBadRequest {
t.Errorf("UpdateProfile oversized avatar status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
// See TestUpdateProfile_OversizedUsernameRejectedBeforeSanitizing for the
// rationale behind this bound: a guard that runs before sanitizing
// rejects in well under a millisecond, while the pre-fix code spends over
// 150ms in sanitizeToFixpoint on this payload before validateAvatarURL's
// length check ever runs.
if elapsed > 150*time.Millisecond {
t.Errorf("UpdateProfile oversized avatar took %v, want well under 150ms (raw field must be bounded before sanitizing, not after)", elapsed)
}
}
// OC-0197: display_name is validated (validateDisplayName) against the raw
// JSON string, before the fixpoint sanitizer's outer html.UnescapeString
// ever runs (that happens later, inside UserService.UpdateProfile's
// cleanText call). So an entity-encoded control or bidi character like
// "&#x202e;" sails through validateDisplayName as harmless ASCII, and is only
// turned into the real U+202E RIGHT-TO-LEFT OVERRIDE character afterwards,
// on its way into storage. TestUpdateProfile_RejectsBadDisplayName
// (avatar_handler_test.go) shows the literal character is correctly
// rejected; this is the entity-encoded bypass of that same guard — the fix
// is to sanitize display_name before validating it, the same order the
// username field above already uses.
func TestUpdateProfile_RejectsEntityEncodedBidiOverrideInDisplayName(t *testing.T) {
database := newAuthTestDB(t)
router := buildProfileRouter(database)
token := profileCreateToken(t, database, "dnentity", 4)
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
"username": "dnentity",
"display_name": "ada&#x202e;gnp.exe",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("entity-encoded bidi override display_name status = %d, want 400; body = %s", rr.Code, rr.Body.String())
}
// Regardless of what the handler answered, the stored row must never end
// up holding a real bidi override character smuggled in via the entity
// encoding — that is the actual harm (it renders wherever the username
// does, in every connected client, once broadcast).
u, err := database.GetUserByUsername(context.Background(), "dnentity")
if err != nil || u == nil {
t.Fatalf("GetUserByUsername: %v, %v", u, err)
}
if u.DisplayName != nil && strings.ContainsRune(*u.DisplayName, '\u202e') {
t.Errorf("stored display_name = %q, contains a real U+202E bidi override smuggled past validateDisplayName via HTML entity", *u.DisplayName)
}
}
// OC-0180: the avatar branch must canonicalize with the same fixpoint
// sanitizer (service.SanitizeText) as the username path above it, not the
// bare bluemonday sanitizer.Sanitize — Sanitize's output is always
+16
View File
@@ -81,6 +81,22 @@ CREATE TABLE IF NOT EXISTS sessions (
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
-- AuthMiddleware falls through to an API-token lookup whenever a bearer
-- token matches no session (auth.ResolveTokenHash), so this table must exist
-- even in upload-only fixtures otherwise an ordinary "no such session"
-- lookup for a garbage/unknown token hits GetActiveAPIToken and fails with a
-- real "no such table" SQL error instead of the intended not-found sentinel.
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
label TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used_at TEXT,
expires_at TEXT,
revoked_at TEXT
);
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
+7 -3
View File
@@ -198,9 +198,13 @@ func deleteAccountAdminGuard(ctx context.Context, tx *sql.Tx, userID int64) erro
args = append(args, userID)
var adminCount int
if err := tx.QueryRowContext(ctx,
fmt.Sprintf(`SELECT COUNT(*) FROM users WHERE role_id IN (%s) AND id != ? AND banned = 0`,
strings.Join(placeholders, ",")),
// notBannedClause is appended outside the Sprintf format string
// (rather than joined into it) because it contains strftime
// verbs like %Y and %H that fmt.Sprintf would otherwise try to
// parse as its own format directives.
query := fmt.Sprintf(`SELECT COUNT(*) FROM users WHERE role_id IN (%s) AND id != ? AND `,
strings.Join(placeholders, ",")) + notBannedClause
if err := tx.QueryRowContext(ctx, query,
args...,
).Scan(&adminCount); err != nil {
return fmt.Errorf("DeleteAccount count admins: %w", err)
+27
View File
@@ -48,6 +48,33 @@ func TestDeleteAccount_AllowedWhenOtherAdminExists(t *testing.T) {
}
}
// TestDeleteAccount_AllowedWhenOtherAdminHasLapsedTempBan locks the guard's
// "is there another usable admin left" count against the same lapsed-ban
// split anonymiseUser and notBannedClause document elsewhere: an admin whose
// temporary ban has expired (banned=1, ban_expires in the past) is fully
// functional per auth.IsEffectivelyBanned, so the raw `banned = 0` filter
// must not make the guard blind to them.
func TestDeleteAccount_AllowedWhenOtherAdminHasLapsedTempBan(t *testing.T) {
database := openMigratedMemory(t)
admin1 := seedUser(t, database, "admin1")
admin2 := seedUser(t, database, "admin2")
setRole(t, database, admin1, 2) // Admin
setRole(t, database, admin2, 2) // Admin
// admin2's temp ban has lapsed: banned stays 1 but ban_expires is in the
// past, so admin2 logs in and administers normally.
if _, err := database.ExecContext(context.Background(),
`UPDATE users SET banned = 1, ban_expires = '2020-01-01 00:00:00' WHERE id = ?`, admin2,
); err != nil {
t.Fatalf("set lapsed temp ban: %v", err)
}
err := database.DeleteAccount(context.Background(), admin1)
if err != nil {
t.Fatalf("DeleteAccount with a lapsed-temp-ban admin present: %v", err)
}
}
func TestDeleteAccount_AdminAllowedWhenOwnerExists(t *testing.T) {
database := openMigratedMemory(t)
ownerID := seedUser(t, database, "owner")
+98 -20
View File
@@ -427,13 +427,22 @@ func runClosePlugins(registry *plugin.Registry) {
// runStartEventPersistence starts the event persister and pruner, returning
// both as (nil, nil) when event persistence is disabled. Extracted from run.
//
// seedHubReplayState runs unconditionally (whenever hub is non-nil), NOT
// gated on cfg.EventPersistence.Enabled: it seeds the hub's seq counter from
// a persisted floor even in ring-buffer-only mode, which is what closes
// OC-0210 — see its doc comment.
func runStartEventPersistence(bgCtx context.Context, log *slog.Logger, cfg *config.Config, hub *ws.Hub, database *db.DB) (*ws.EventPersister, <-chan struct{}) {
if !cfg.EventPersistence.Enabled || hub == nil {
if hub == nil {
return nil, nil
}
seedHubReplayState(bgCtx, hub, database, log)
if !cfg.EventPersistence.Enabled {
return nil, nil
}
persister := ws.NewEventPersister(
database,
4096,
@@ -813,29 +822,65 @@ func loadPinnedCert(path string) []byte {
return block.Bytes
}
// seedHubReplayState restores the hub's monotonic seq counter from the
// persisted MAX(events.seq) so wrapped-payload seqs stay monotonic across
// restarts. Without this, the events table accumulates rows whose payload
// seqs reset to 1 after every restart, breaking the reconnect "events since
// last_seq" contract.
// wsSeqFloorSettingKey is the generic settings-table key (see db.GetSetting /
// db.SetSetting) seedHubSeqFloor persists its reserved floor under.
const wsSeqFloorSettingKey = "ws_seq_floor"
// wsSeqFloorReserve is the block seedHubSeqFloor reserves above the persisted
// floor on every single boot (OC-0210). It only has to exceed the number of
// hub-sequenced broadcasts any one boot could plausibly emit before its own
// next restart — comfortably true at 1e9 for a self-hosted chat server — so
// this leaves an enormous safety margin while uint64's range still allows
// billions of restarts before the floor could ever wrap.
const wsSeqFloorReserve = 1_000_000_000
// seedHubReplayState seeds the hub's monotonic seq counter at startup from
// two independent, composable sources — both go through hub.SeedSeq, which
// only ever moves h.seq forward (CAS-max), so it doesn't matter which of the
// two runs first or whether either is available:
//
// It also forces every client resuming from at or before that restored seq
// onto the full-ready path for this boot. h.seq is persisted and restored
// here, but the paired watermark that tells a resuming client whether a
// channel-visibility change happened since its last_seq
// (visibilityChangeSeq) is in-memory only and always starts at 0 on a fresh
// process — see ws/hub_events.go's mustFullResync. Channel-visibility
// changes made to an offline client (RefreshChannelVisibility,
// revokeUnreadableChannels) are sent as targeted, unsequenced messages that
// are never written to the events table, so replay can never recover them.
// Without the MarkVisibilityChanged call below, a client resuming with
// last_seq at or before the pre-restart max sails straight through
// mustFullResync's zeroed watermark and can silently miss a visibility
// change it should have converged on.
// 1. seedHubSeqFloor (below) reserves and persists a fresh block of seq
// space on every boot, regardless of whether event persistence is
// enabled. This is what closes OC-0210: previously this function did
// nothing at all when event_persistence.enabled is false (the
// documented "ring-buffer-only behaviour", config.go's
// EventPersistenceConfig.Enabled), so every boot's h.seq — and
// therefore its ring buffer's first entries — started back at 0/1. A
// client reconnecting with a last_seq remembered from a PRIOR boot
// could then coincidentally land inside the new boot's own live ring
// window: EventRingBuffer.EventsSinceFiltered has no way to tell that
// watermark apart from a legitimate one from this boot, and would
// silently serve a partial cross-epoch replay as if it were an
// ordinary resume. Seeding a floor far above anything a single boot
// could reach guarantees every previous boot's real seq values now sit
// below the new ring buffer's oldest entry, so a stale last_seq is
// correctly rejected by the pre-existing "afterSeq <= oldestSeq" guard
// in ringbuffer.go and falls through to a full ready instead
// (serve.go's handleReconnect, the `events == nil` branch) — the same
// path any other unrecoverable resume already takes, with no protocol
// change required.
// 2. When event persistence is enabled and the events table has history,
// MAX(events.seq) is exact (not a heuristic reserve) and naturally
// wins if it is the higher of the two. This branch is also what forces
// the paired visibilityChangeSeq watermark forward via
// MarkVisibilityChanged: h.seq is restored here, but the watermark
// that tells a resuming client whether a channel-visibility change
// happened since its last_seq (visibilityChangeSeq) is in-memory only
// and always starts at 0 on a fresh process — see
// ws/hub_events.go's mustFullResync. Channel-visibility changes made to
// an offline client (RefreshChannelVisibility, revokeUnreadableChannels)
// are sent as targeted, unsequenced messages that are never written to
// the events table, so replay can never recover them. Without the
// MarkVisibilityChanged call below, a client resuming with last_seq at
// or before the pre-restart max would sail straight through
// mustFullResync's zeroed watermark and could silently miss a
// visibility change it should have converged on.
func seedHubReplayState(ctx context.Context, hub *ws.Hub, database *db.DB, log *slog.Logger) {
seedHubSeqFloor(ctx, hub, database, log)
maxSeq, seedErr := database.GetMaxEventSeq(ctx)
if seedErr != nil {
log.Warn("event persistence: failed to read MAX(events.seq); starting hub seq from 0", "error", seedErr)
log.Warn("event persistence: failed to read MAX(events.seq); hub seq still advanced from the persisted floor for this boot", "error", seedErr)
return
}
if maxSeq <= 0 {
@@ -846,6 +891,39 @@ func seedHubReplayState(ctx context.Context, hub *ws.Hub, database *db.DB, log *
hub.MarkVisibilityChanged()
}
// seedHubSeqFloor reserves and persists a fresh block of the hub's sequence
// space on every boot, independent of event persistence (OC-0210) — see
// seedHubReplayState's doc for why this is what actually closes the bug. A
// read or write failure against the settings table is logged and skipped
// rather than fatal: it leaves this one boot with the pre-fix exposure
// (plain Phase A ring-buffer behaviour) instead of blocking startup over a
// heuristic safety net.
func seedHubSeqFloor(ctx context.Context, hub *ws.Hub, database *db.DB, log *slog.Logger) {
var floor uint64
raw, err := database.GetSetting(ctx, wsSeqFloorSettingKey)
switch {
case err == nil:
parsed, perr := strconv.ParseUint(raw, 10, 64)
if perr != nil {
log.Warn("event persistence: stored ws seq floor is not a valid uint64, resetting to 0", "value", raw, "error", perr)
break
}
floor = parsed
case errors.Is(err, db.ErrNotFound):
// No prior boot has ever reserved a floor — start from 0.
default:
log.Warn("event persistence: failed to read persisted ws seq floor; hub seq not advanced this boot", "error", err)
return
}
newFloor := floor + wsSeqFloorReserve
if err := database.SetSetting(ctx, wsSeqFloorSettingKey, strconv.FormatUint(newFloor, 10)); err != nil {
log.Warn("event persistence: failed to persist advanced ws seq floor; hub seq not advanced this boot", "error", err)
return
}
hub.SeedSeq(newFloor)
}
// printBanner writes the startup banner to stderr (so it doesn't mix with
// the structured log output on stdout).
func printBanner(cfg *config.Config, ver string, tls bool) {
+171
View File
@@ -16,6 +16,7 @@ import (
"github.com/owncord/server/admin"
"github.com/owncord/server/auth"
"github.com/owncord/server/config"
"github.com/owncord/server/db"
"github.com/owncord/server/ws"
)
@@ -166,3 +167,173 @@ func TestSeedHubReplayState_ForcesFullResyncForOfflineClient(t *testing.T) {
bufTier, dbTier, fullTier)
}
}
// waitForFirstRingBufferEntry polls hub's ring buffer until it holds at
// least one entry and returns that entry's seq, or fails the test after a
// timeout. BroadcastToAll enqueues onto the hub's dispatch channel and
// returns before a seq is actually assigned, so tests that need to know a
// real assigned seq must synchronize on this instead of assuming one.
func waitForFirstRingBufferEntry(t *testing.T, hub *ws.Hub) uint64 {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
if oldest := hub.ReplayBuffer().OldestSeq(); oldest != 0 {
return oldest
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for the first ring buffer entry to land")
}
time.Sleep(time.Millisecond)
}
}
// waitForRingBufferNewestAtLeast polls hub's ring buffer until its newest
// entry's seq is >= target, or fails the test after a timeout. See
// waitForFirstRingBufferEntry on why this can't be a fixed sleep.
func waitForRingBufferNewestAtLeast(t *testing.T, hub *ws.Hub, target uint64) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for {
if newest := hub.ReplayBuffer().NewestSeq(); newest >= target {
return
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for hub ring buffer newest seq to reach >= %d (currently %d)",
target, hub.ReplayBuffer().NewestSeq())
}
time.Sleep(time.Millisecond)
}
}
// TestRunStartEventPersistence_DisabledMode_StaleLastSeqForcesFullResync pins
// OC-0210: with event_persistence.enabled=false ("ring-buffer-only
// behaviour", config.go's EventPersistenceConfig.Enabled doc), every boot's
// h.seq previously started at 0 with an empty ring buffer, with nothing to
// distinguish this boot's own watermarks from a PRIOR boot's. A reconnecting
// client carrying a last_seq from a prior process's epoch was checked only
// against whatever the new epoch's ring buffer happened to hold; if the new
// epoch's traffic (e.g. other clients reconnecting first) had pushed seq past
// that stale value, EventsSinceFiltered reported it as an ordinary in-window
// replay instead of refusing it, silently handing back a different epoch's
// events as if they were a contiguous resume.
//
// This simulates exactly the repro: hub "A" (a prior boot) runs with
// persistence disabled and a client observes 40 broadcasts go by (its
// last_seq is whatever the 40th one's seq turns out to be — captured
// dynamically here rather than hardcoded, since the fix changes what that
// number actually is). Hub A is then stopped (restart) and a fresh hub "B" is
// booted with the same disabled config; other clients' traffic pushes hub B's
// own (unrelated) epoch's seq past that same watermark, then the client
// reconnects against hub B with its old last_seq — a watermark that has never
// existed in hub B's epoch. That resume must be forced onto the full-ready
// path; before the fix it silently resolves via the ordinary buffer tier
// instead.
func TestRunStartEventPersistence_DisabledMode_StaleLastSeqForcesFullResync(t *testing.T) {
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
defer database.Close() //nolint:errcheck
if err := db.Migrate(database); err != nil {
t.Fatalf("db.Migrate: %v", err)
}
ctx := context.Background()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
cfg := &config.Config{EventPersistence: config.EventPersistenceConfig{Enabled: false}}
userID, err := database.CreateUser(ctx, "oc-0210-user", "hash", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if _, err := database.CreateSession(ctx, userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
limiter := auth.NewRateLimiter()
// --- Prior boot: hub A runs with persistence disabled. A client's
// last_seq ends up as whatever the 40th broadcast's real seq turns out to
// be — captured dynamically so this test holds regardless of what value
// scheme is in effect (raw 1..N pre-fix, or a seeded floor post-fix). ---
hubOld := ws.NewHub(database, limiter, nil)
go hubOld.Run()
if persister, prunerDone := runStartEventPersistence(ctx, log, cfg, hubOld, database); persister != nil || prunerDone != nil {
t.Fatalf("runStartEventPersistence with Enabled=false: want (nil, nil), got (%v, %v)", persister, prunerDone)
}
for range 40 {
hubOld.BroadcastToAll([]byte(`{"type":"broadcast"}`))
}
oldFirstSeq := waitForFirstRingBufferEntry(t, hubOld)
staleLastSeq := oldFirstSeq + 39 // the 40th broadcast's seq: what a real client's lastSeq tracker would hold
waitForRingBufferNewestAtLeast(t, hubOld, staleLastSeq)
hubOld.Stop()
// --- Restart: hub B is a brand-new process-equivalent hub, same disabled
// config, same (in-memory but never touched by persistence) database. ---
hubNew := ws.NewHub(database, limiter, nil)
go hubNew.Run()
defer hubNew.Stop()
if persister, prunerDone := runStartEventPersistence(ctx, log, cfg, hubNew, database); persister != nil || prunerDone != nil {
t.Fatalf("runStartEventPersistence with Enabled=false: want (nil, nil), got (%v, %v)", persister, prunerDone)
}
// Other clients reconnect first and push hub B's own new epoch forward by
// 60 broadcasts — enough to overtake staleLastSeq pre-fix (repro's 1..60
// window covering 40) and trivially so post-fix (the seeded floor alone
// already exceeds it).
for range 60 {
hubNew.BroadcastToAll([]byte(`{"type":"broadcast"}`))
}
newFirstSeq := waitForFirstRingBufferEntry(t, hubNew)
newTargetSeq := newFirstSeq + 59
waitForRingBufferNewestAtLeast(t, hubNew, newTargetSeq)
if newTargetSeq <= staleLastSeq {
t.Fatalf("test setup invariant broken: hub B's epoch (reached %d) never overtook the stale watermark (%d)", newTargetSeq, staleLastSeq)
}
handler := ws.ServeWS(hubNew, database, []string{"*"}, 0)
srv := httptest.NewServer(handler)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
conn, dialResp, dialErr := websocket.Dial(dialCtx, wsURL, nil)
if dialResp != nil && dialResp.Body != nil {
_ = dialResp.Body.Close()
}
if dialErr != nil {
t.Fatalf("websocket.Dial: %v", dialErr)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
// staleLastSeq is a watermark from hub A's epoch. It sits inside hub B's
// own live ring window, but nothing about it describes hub B's history —
// it must not be served by ordinary replay.
authMsg := map[string]any{
"type": "auth",
"payload": map[string]any{
"token": token,
"last_seq": staleLastSeq,
},
}
raw, _ := json.Marshal(authMsg)
if err := conn.Write(dialCtx, websocket.MessageText, raw); err != nil {
t.Fatalf("write auth: %v", err)
}
if _, _, err := conn.Read(dialCtx); err != nil {
t.Fatalf("read handshake response: %v", err)
}
bufTier, dbTier, fullTier := hubNew.ReconnectTierStats()
if fullTier != 1 {
t.Fatalf("reconnect tiers (buffer=%d db=%d full=%d): want full=1 — a last_seq from a prior epoch must never be served by ring-buffer replay in ring-buffer-only mode, since the server has no way to tell it apart from an in-epoch watermark",
bufTier, dbTier, fullTier)
}
}
+8 -4
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"log/slog"
"time"
"unicode/utf8"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
@@ -179,9 +178,14 @@ func (s *ChannelService) HandlePresenceUpdate(ctx context.Context, userID int64,
var cleaned *string
if customStatus != nil {
text := cleanText(*customStatus)
if utf8.RuneCountInString(text) > MaxCustomStatusLen {
return nil, fmt.Errorf("%w: custom_status must be at most %d characters", ErrBadRequest, MaxCustomStatusLen)
// OC-0195: bound the raw bytes before cleanText (sanitizeToFixpoint)
// runs — see cleanTextBounded's doc comment (user.go). This path is
// reachable over the WS presence_update frame, whose read limit is
// config.MaxMessageBytes (1 MiB), far larger than any REST body that
// reaches the equivalent guard on SetCustomStatus/UpdateProfile.
text, err := cleanTextBounded(*customStatus, MaxCustomStatusLen, "custom_status")
if err != nil {
return nil, err
}
cleaned = nullable(text)
}
+10 -7
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"log/slog"
"time"
"unicode/utf8"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
@@ -231,9 +230,11 @@ func (s *DMService) CreateGroupDM(ctx context.Context, userID int64, recipientID
return nil, fmt.Errorf("%w: a group DM holds at most %d users", ErrBadRequest, db.MaxGroupDMParticipants)
}
cleanName := cleanText(name)
if utf8.RuneCountInString(cleanName) > MaxGroupDMNameLen {
return nil, fmt.Errorf("%w: name must be at most %d characters", ErrBadRequest, MaxGroupDMNameLen)
// OC-0195 sibling: bound the raw bytes before cleanText (sanitizeToFixpoint)
// runs — see cleanTextBounded's doc comment (user.go).
cleanName, err := cleanTextBounded(name, MaxGroupDMNameLen, "name")
if err != nil {
return nil, err
}
for _, rid := range unique {
@@ -320,9 +321,11 @@ func (s *DMService) RenameGroupDM(ctx context.Context, userID, channelID int64,
return nil, fmt.Errorf("%w: only group DMs can be named", ErrBadRequest)
}
cleanName := cleanText(name)
if utf8.RuneCountInString(cleanName) > MaxGroupDMNameLen {
return nil, fmt.Errorf("%w: name must be at most %d characters", ErrBadRequest, MaxGroupDMNameLen)
// OC-0195 sibling: bound the raw bytes before cleanText (sanitizeToFixpoint)
// runs — see cleanTextBounded's doc comment (user.go).
cleanName, err := cleanTextBounded(name, MaxGroupDMNameLen, "name")
if err != nil {
return nil, err
}
if err := s.st.SetDMChannelName(ctx, channelID, cleanName); err != nil {
+65
View File
@@ -3,7 +3,9 @@ package service
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/owncord/server/db"
)
@@ -62,6 +64,69 @@ func TestDMService_CreateGroupDM_RefusesBannedRecipient(t *testing.T) {
}
}
// OC-0194: same defect as OC-0192/OC-0195 (see
// TestUpdateProfile_OversizedDisplayNameAndAboutRejectedBeforeSanitizing and
// TestHandlePresenceUpdate_OversizedCustomStatusRejectedBeforeSanitizing) but
// reached via CreateGroupDM. /api/v1/dms carries no rate limiter, and
// CreateGroupDM runs cleanText(name) *before* the recipient-existence/ban/
// block checks, so an adversarial nested-entity name pays the full quadratic
// sanitizeToFixpoint cost even for a request that is going to 404 on its
// recipients. The raw-byte guard must reject on cheap byte length alone.
func TestDMService_CreateGroupDM_OversizedNameRejectedBeforeSanitizing(t *testing.T) {
database := newTestDB(t)
seedUser(t, database, &db.User{ID: 1, Username: "alice"})
svc := NewDMService(database)
// Adversarial nested-entity payload (16 KB) — see sanitizeToFixpoint's
// doc comment (message.go) for why this shape is quadratic to sanitize.
huge := "&" + strings.Repeat("amp;", 4000) + "lt;"
start := time.Now()
// Recipients 999998/999999 do not exist. The guard must fire before the
// per-recipient GetUserByID/ban checks reach the database, matching the
// order CreateGroupDM actually runs them in.
_, err := svc.CreateGroupDM(context.Background(), 1, []int64{999998, 999999}, huge)
elapsed := time.Since(start)
if !errors.Is(err, ErrBadRequest) {
t.Errorf("CreateGroupDM with oversized name err = %v, want ErrBadRequest", err)
}
// A guard that runs before sanitizing rejects in well under a
// millisecond; the pre-fix code spends well over 150ms in
// sanitizeToFixpoint on this payload before the rune-count check ever
// runs. 150ms gives generous margin over noise while staying far below
// the unguarded cost.
if elapsed > 150*time.Millisecond {
t.Errorf("CreateGroupDM with oversized name took %v, want well under 150ms (raw field must be bounded before sanitizing)", elapsed)
}
}
// OC-0194 sibling: RenameGroupDM runs the identical cleanText(name) call and
// must be bounded the same way as CreateGroupDM.
func TestDMService_RenameGroupDM_OversizedNameRejectedBeforeSanitizing(t *testing.T) {
database := newTestDB(t)
seedUser(t, database, &db.User{ID: 1, Username: "alice"})
seedUser(t, database, &db.User{ID: 2, Username: "bob"})
seedUser(t, database, &db.User{ID: 3, Username: "carol"})
svc := NewDMService(database)
created, err := svc.CreateGroupDM(context.Background(), 1, []int64{2, 3}, "")
if err != nil {
t.Fatalf("setup CreateGroupDM: %v", err)
}
huge := "&" + strings.Repeat("amp;", 4000) + "lt;"
start := time.Now()
_, err = svc.RenameGroupDM(context.Background(), 1, created.Channel.ID, huge)
elapsed := time.Since(start)
if !errors.Is(err, ErrBadRequest) {
t.Errorf("RenameGroupDM with oversized name err = %v, want ErrBadRequest", err)
}
if elapsed > 150*time.Millisecond {
t.Errorf("RenameGroupDM with oversized name took %v, want well under 150ms (raw field must be bounded before sanitizing)", elapsed)
}
}
// cancelAfterCreateGroupDMStore wraps a real *db.DB and cancels a context the
// instant CreateGroupDMChannel returns successfully — simulating a client
// disconnect that lands exactly in the gap between the channel's commit and
+10 -1
View File
@@ -176,7 +176,16 @@ func (s *MessageService) applyMentionCounts(ctx context.Context, channelID, msgI
// here and a literal comparison would ping them with @here — the one
// thing "appear offline" is meant to stop. Collapsing first makes
// @here agree with what everyone else can see of that reader.
if set.HereOnly && db.BroadcastStatus(r.Status) == db.StatusOffline {
//
// That column check alone is not enough: users.status keeps a
// *chosen* idle/dnd across a disconnect by design
// (MarkUserDisconnected only ever rewrites "online" -> "offline"),
// so a signed-out reader whose last status was idle/dnd would still
// read as non-offline here. s.online (nil-safe) applies the read
// path's "no live connection is offline, whatever the row says"
// rule (ws/serve_ready.go presentableMembers) to close that gap.
if set.HereOnly && (db.BroadcastStatus(r.Status) == db.StatusOffline ||
(s.online != nil && !s.online(r.UserID))) {
continue
}
recipients[r.UserID] = struct{}{}
+31
View File
@@ -301,6 +301,37 @@ func TestSendMessage_HereSkipsInvisibleUsers(t *testing.T) {
}
}
// TestSendMessage_HereSkipsDisconnectedIdleDndUsers locks OC-0223: @here must
// treat a reader with no live connection as offline even when their stored
// status is idle/dnd, matching the read path's "no live connection is
// offline, whatever the row says" rule (ws/serve_ready.go presentableMembers).
// MarkUserDisconnected only ever rewrites "online" -> "offline" — an idle/dnd
// choice survives the disconnect by design, so a bare
// db.BroadcastStatus(r.Status) == db.StatusOffline test can never catch a
// disconnected idle/dnd reader without also consulting live connection state.
func TestSendMessage_HereSkipsDisconnectedIdleDndUsers(t *testing.T) {
svc, _, database := newMentionFixture(t)
// bob's last chosen status was "dnd" before disconnecting (mirrors what
// MarkUserDisconnected leaves behind for a non-"online" status).
if err := database.UpdateUserStatus(context.Background(), 2, db.StatusDND); err != nil {
t.Fatalf("UpdateUserStatus(dnd): %v", err)
}
// bob has no live connection.
svc.SetOnlineChecker(func(userID int64) bool { return userID != 2 })
sendAs(t, svc, 4, "@here quick question")
if got := mentionCount(t, database, 2); got != 0 {
t.Errorf("disconnected dnd bob mention_count = %d, want 0", got)
}
// A plain @everyone still reaches them: only @here narrows on presence.
sendAs(t, svc, 4, "@everyone meeting now")
if got := mentionCount(t, database, 2); got != 1 {
t.Errorf("disconnected dnd bob @everyone mention_count = %d, want 1", got)
}
}
// TestSendMessage_EveryoneSkipsUsersWithoutRead locks that the @everyone
// fan-out honors per-channel denies, not just the base role mask.
func TestSendMessage_EveryoneSkipsUsersWithoutRead(t *testing.T) {
+20
View File
@@ -133,6 +133,26 @@ type MessageService struct {
// tests swap it for an inline runner via RunBackgroundInlineForTest so they
// can read the counts deterministically right after a send.
bg func(fn func())
// online reports whether userID currently holds a live connection. It is
// wired by the ws layer (Hub.IsUserConnected) after both are constructed,
// so @here can apply the same "no live connection is offline, whatever the
// row stores" rule the read path uses (ws/serve_ready.go
// presentableMembers) instead of trusting users.status alone — that column
// keeps a *chosen* idle/dnd/invisible across a disconnect by design
// (MarkUserDisconnected only ever rewrites "online" -> "offline"), so a
// disconnected idle/dnd reader would otherwise still collect an @here
// badge. nil (the zero value, e.g. in tests and any caller with no hub)
// means "no live-connection information available" and applies no extra
// narrowing, preserving prior behavior.
online func(userID int64) bool
}
// SetOnlineChecker wires the live-connection predicate @here's offline
// narrowing consults in addition to users.status. Passing nil clears it. Safe
// to call once at startup (the ws layer, after constructing both the Hub and
// the Services) or from a test.
func (s *MessageService) SetOnlineChecker(online func(userID int64) bool) {
s.online = online
}
// NewMessageService creates a MessageService.
+81
View File
@@ -209,6 +209,49 @@ func TestUpdateProfile_RejectsOverlongFields(t *testing.T) {
}
}
// OC-0192: UpdateProfile is the one function every transport (the REST
// handler, and any future non-REST caller — see ProfilePatch's doc comment)
// goes through, so the raw-length bound belongs here, not only in the
// handler. cleanText (sanitizeToFixpoint) is quadratic in input length, and
// nothing bounds DisplayName/About before line 140/143 run it — the rune-
// count checks there run cleanText's full (expensive) output before ever
// looking at how long it is. A caller that hands UpdateProfile an
// adversarial nested-entity payload must be rejected on a cheap byte-length
// check, not after the fixpoint sanitizer has already paid its cost on it.
func TestUpdateProfile_OversizedDisplayNameAndAboutRejectedBeforeSanitizing(t *testing.T) {
svc, _ := newUserSvc(t)
ctx := context.Background()
// Adversarial nested-entity payload (16 KB) — see sanitizeToFixpoint's
// doc comment (message.go) for why this shape is quadratic to sanitize.
huge := "&" + strings.Repeat("amp;", 4000) + "lt;"
start := time.Now()
_, err := svc.UpdateProfile(ctx, 1, ProfilePatch{Username: "ada", DisplayName: &huge})
elapsed := time.Since(start)
if !errors.Is(err, ErrBadRequest) {
t.Errorf("oversized display_name err = %v, want ErrBadRequest", err)
}
// A guard that runs before sanitizing rejects in well under a
// millisecond; the pre-fix code spends well over 150ms in
// sanitizeToFixpoint on this payload before the rune-count check ever
// runs. 150ms gives generous margin over noise while staying far below
// the unguarded cost.
if elapsed > 150*time.Millisecond {
t.Errorf("oversized display_name took %v, want well under 150ms (raw field must be bounded before sanitizing)", elapsed)
}
start = time.Now()
_, err = svc.UpdateProfile(ctx, 1, ProfilePatch{Username: "ada", About: &huge})
elapsed = time.Since(start)
if !errors.Is(err, ErrBadRequest) {
t.Errorf("oversized about err = %v, want ErrBadRequest", err)
}
if elapsed > 150*time.Millisecond {
t.Errorf("oversized about took %v, want well under 150ms (raw field must be bounded before sanitizing)", elapsed)
}
}
func TestSetCustomStatus_RoundTripClearAndBound(t *testing.T) {
svc, database := newUserSvc(t)
ctx := context.Background()
@@ -293,6 +336,44 @@ func TestHandlePresenceUpdate_AcceptsInvisibleAndCarriesCustomStatus(t *testing.
}
}
// OC-0195: same defect as OC-0192 (TestUpdateProfile_OversizedDisplayNameAndAboutRejectedBeforeSanitizing)
// but reached over presence_update instead of PATCH /users/me. HandlePresenceUpdate
// applied MaxCustomStatusLen to cleanText's *output*, so an adversarial
// nested-entity payload paid the full quadratic sanitizeToFixpoint cost before
// ever being measured. The WS read limit (config.MaxMessageBytes, 1 MiB) admits
// a payload here far larger than PATCH /users/me's body ever could, and this
// runs on the connection's own readPump goroutine.
func TestHandlePresenceUpdate_OversizedCustomStatusRejectedBeforeSanitizing(t *testing.T) {
database := newTestDB(t)
seedUser(t, database, &db.User{ID: 1, Username: "ada", PasswordHash: "h"})
svc := NewChannelService(database, NewPermissionService(database, permissions.NewChecker(database)))
ctx := context.Background()
// Adversarial nested-entity payload (16 KB) — see sanitizeToFixpoint's
// doc comment (message.go) for why this shape is quadratic to sanitize.
huge := "&" + strings.Repeat("amp;", 4000) + "lt;"
start := time.Now()
_, err := svc.HandlePresenceUpdate(ctx, 1, db.StatusOnline, &huge, nil)
elapsed := time.Since(start)
if !errors.Is(err, ErrBadRequest) {
t.Errorf("oversized custom_status err = %v, want ErrBadRequest", err)
}
// A guard that runs before sanitizing rejects in well under a
// millisecond; the pre-fix code spends well over 150ms in
// sanitizeToFixpoint on this payload before the rune-count check ever
// runs. 150ms gives generous margin over noise while staying far below
// the unguarded cost.
if elapsed > 150*time.Millisecond {
t.Errorf("oversized custom_status took %v, want well under 150ms (raw field must be bounded before sanitizing)", elapsed)
}
// The rejected call must not have committed the status either.
u, _ := database.GetUserByID(ctx, 1)
if u.Status == db.StatusOnline {
t.Error("a rejected presence_update must not commit the status")
}
}
func TestHandlePresenceUpdate_RejectsUnknownStatusAndOverlongText(t *testing.T) {
database := newTestDB(t)
seedUser(t, database, &db.User{ID: 1, Username: "ada", PasswordHash: "h"})
+47 -3
View File
@@ -114,6 +114,33 @@ func cleanText(v string) string {
return strings.TrimSpace(sanitizeToFixpoint(v))
}
// cleanTextBounded is cleanText plus the raw-byte guard OC-0192 established
// for UpdateProfile's DisplayName/About fields, generalized for every other
// free-text field that runs through cleanText: SetCustomStatus,
// HandlePresenceUpdate's custom_status, and group DM names (OC-0195).
//
// cleanText's sanitizeToFixpoint pass is quadratic in input length, so a
// bound applied only to its *output* (a plain rune-count check on the
// cleaned string) still lets an adversarial nested-entity payload pay the
// full sanitize cost first — it can even sanitize down to something well
// under maxRunes and be silently accepted, having spent seconds of CPU to
// get there. The byte-length pre-check runs before cleanText ever does, on
// the untouched input, so the cost of rejecting an oversized value is
// O(len(v)) instead of the sanitizer's cost. *4 is deliberately looser than
// maxRunes — it exists only to keep the sanitizer from ever seeing a
// pathological payload, not to duplicate the real (rune-count) bound, which
// still runs afterward on the cleaned, trimmed value.
func cleanTextBounded(v string, maxRunes int, fieldName string) (string, error) {
if len(v) > maxRunes*4 {
return "", fmt.Errorf("%w: %s must be at most %d characters", ErrBadRequest, fieldName, maxRunes)
}
cleaned := cleanText(v)
if utf8.RuneCountInString(cleaned) > maxRunes {
return "", fmt.Errorf("%w: %s must be at most %d characters", ErrBadRequest, fieldName, maxRunes)
}
return cleaned, nil
}
// resolveOptional picks the column value for one nullable text field: the
// sanitized patch when it was supplied, the existing row otherwise.
func resolveOptional(patch *string, existing *string) *string {
@@ -137,6 +164,23 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, patch Pro
span.End()
}()
// OC-0192: bound the raw bytes before either reaches cleanText
// (sanitizeToFixpoint) below — its cost is quadratic in input length,
// and an adversarial nested-entity payload can sanitize down to
// something well under the rune-count bound while still costing seconds
// of CPU to get there, so the rune-count check alone never rejects it
// early. This is the same cheap byte-length pre-check the handler uses
// for username/avatar (profile_handler.go); *4 still admits any
// legitimate UTF-8 value at the rune bound. UpdateProfile is the one
// function every transport reaches (see ProfilePatch's doc comment), so
// the guard belongs here rather than only in the REST handler.
if patch.DisplayName != nil && len(*patch.DisplayName) > MaxDisplayNameLen*4 {
return nil, fmt.Errorf("%w: display_name must be at most %d characters", ErrBadRequest, MaxDisplayNameLen)
}
if patch.About != nil && len(*patch.About) > MaxAboutLen*4 {
return nil, fmt.Errorf("%w: about must be at most %d characters", ErrBadRequest, MaxAboutLen)
}
if patch.DisplayName != nil && utf8.RuneCountInString(cleanText(*patch.DisplayName)) > MaxDisplayNameLen {
return nil, fmt.Errorf("%w: display_name must be at most %d characters", ErrBadRequest, MaxDisplayNameLen)
}
@@ -199,9 +243,9 @@ func (s *UserService) UpdateProfile(ctx context.Context, userID int64, patch Pro
// value persists across reconnects and is cleared explicitly on logout, which
// is why it is stored rather than held on the connection.
func (s *UserService) SetCustomStatus(ctx context.Context, userID int64, text string) error {
cleaned := cleanText(text)
if utf8.RuneCountInString(cleaned) > MaxCustomStatusLen {
return fmt.Errorf("%w: custom_status must be at most %d characters", ErrBadRequest, MaxCustomStatusLen)
cleaned, err := cleanTextBounded(text, MaxCustomStatusLen, "custom_status")
if err != nil {
return err
}
if err := s.st.UpdateUserCustomStatus(ctx, userID, nullable(cleaned)); err != nil {
return fmt.Errorf("%w: failed to update custom status: %v", ErrInternal, err)
+12 -1
View File
@@ -119,7 +119,18 @@ func (h *Hub) handleMessageSessionRecheck(c *Client) bool {
if shouldCheck && c.tokenHash != "" {
result, dbErr := h.db.GetSessionWithBanStatus(c.ctx, c.tokenHash)
if dbErr != nil || result == nil || auth.IsSessionExpired(result.ExpiresAt) {
if dbErr != nil {
// A failed read says nothing about this session's validity —
// kicking the client on a transient DB error (SQLITE_BUSY, an
// I/O error, a maintenance window) would be a false positive.
// Skip this recheck; the next one retries, and
// sweepRevokedSessions remains the time-based backstop for
// idle connections. Matches sweepRevokedSessions's identical
// rule for a failed batch lookup (hub_sweep.go).
slog.Warn("ws session recheck: lookup failed, skipping", "user_id", c.userID, "err", dbErr)
return false
}
if result == nil || auth.IsSessionExpired(result.ExpiresAt) {
slog.Info("ws session expired, closing connection", "user_id", c.userID)
h.kickClient(c)
return true
+9 -1
View File
@@ -3,6 +3,7 @@ package ws
import (
"context"
"errors"
"log/slog"
"github.com/owncord/server/db"
"github.com/owncord/server/service"
@@ -182,6 +183,13 @@ func serviceErrorToResult(err error) Result {
case errors.Is(err, service.ErrConflict):
return Result{Error: ClientError{Code: ErrCodeConflict, Message: err.Error()}}
default:
return Result{Error: ClientError{Code: ErrCodeInternal, Message: err.Error()}}
// Internal errors (service.ErrInternal wrappers embed the underlying
// driver error via %v) must not reach the client verbatim, matching
// the REST twin writeServiceError (Server/api/channel_handler.go) and
// every other ErrCodeInternal site in this package. Log server-side
// since this is the only ErrCodeInternal path whose caller
// (handlers.go) skips its own logging for ClientError results.
slog.Error("ws service internal error", "err", err)
return Result{Error: ClientError{Code: ErrCodeInternal, Message: "internal error"}}
}
}
+5
View File
@@ -179,6 +179,11 @@ func NewHub(database *db.DB, limiter *auth.RateLimiter, svc *service.Services) *
callDeps.DMSvc = svc.DMs
h.messageSvc = svc.Messages
h.perms = svc.Permissions
// So @here's offline narrowing can tell a disconnected idle/dnd reader
// (users.status keeps their last *chosen* value across a disconnect)
// from one who is actually still connected — the same live-connection
// rule presentableMembers applies to the members array.
svc.Messages.SetOnlineChecker(h.IsUserConnected)
}
registerChatHandlers(reg, chatDeps)
@@ -0,0 +1,61 @@
package ws
// Internal test for OC-0211: handleMessageSessionRecheck must not treat a
// transient DB error the same as a genuinely revoked/expired session. The
// sibling sweep in hub_sweep.go (sweepRevokedSessions) already documents and
// implements the correct rule for the identical failure: "a failed batch
// lookup says nothing about any individual session — kicking everyone on a
// transient DB error would be a mass disconnect. Skip this sweep; the next
// tick retries." handleMessageSessionRecheck disagreed, kicking the client on
// dbErr != nil exactly like a deleted/expired session.
import (
"context"
"testing"
"github.com/owncord/server/auth"
)
func TestHandleMessageSessionRecheck_TransientDBErrorDoesNotKick(t *testing.T) {
database := newHarvestVoiceDB(t)
uid := seedHarvestVoiceUser(t, database, "recheck-dberr")
tokenHash := "tok-recheck-dberr"
if _, err := database.CreateSession(context.Background(), uid, tokenHash, "test-device", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
h := NewHub(database, auth.NewRateLimiter(), nil)
c := NewTestClient(h, uid, make(chan []byte, 8))
c.tokenHash = tokenHash
// Put the client one message away from the periodic recheck boundary, so
// the very next call to handleMessageSessionRecheck triggers the DB read.
c.msgCount = SessionCheckInterval - 1
h.clients[uid] = c
// Force GetSessionWithBanStatus to fail with a genuine DB error (not
// sql.ErrNoRows, which the production code already treats as "session
// gone" — that path is not in question here). Closing the underlying
// connection pool reproduces the same dbErr != nil branch a transient
// SQLITE_BUSY, I/O error, or maintenance window would.
if err := database.Close(); err != nil {
t.Fatalf("database.Close: %v", err)
}
closed := h.handleMessageSessionRecheck(c)
if closed {
t.Fatalf("handleMessageSessionRecheck reported the connection closed on a transient DB lookup error; " +
"a failed read is not evidence the session is invalid (compare sweepRevokedSessions, which skips on the same failure)")
}
h.mu.RLock()
_, stillConnected := h.clients[uid]
h.mu.RUnlock()
if !stillConnected {
t.Fatalf("client was removed from h.clients on a transient DB lookup error during session recheck")
}
if c.isSendClosed() {
t.Fatalf("client's send channels were closed on a transient DB lookup error during session recheck")
}
}
@@ -0,0 +1,100 @@
package ws
// oc_0219_voice_join_rollback_unsubscribe_test.go — regression test for
// finding OC-0219.
//
// rollbackVoiceJoin clears the client's voice channel ID but never drops its
// VoiceTopic subscription. voiceJoinComplete subscribes the joiner to
// VoiceTopic(channelID) (voice_join.go) BEFORE it reads back the channel's
// existing participants via GetChannelVoiceStates; when that read fails, the
// handler calls rollbackVoiceJoin to undo the join. Every other path that
// takes a client out of voice while its WS stays up (clearVoiceAndUnsubscribe
// in voice_leave.go, and its callers) also drops the VoiceTopic subscription
// — rollbackVoiceJoin is the only one that does not. A socket left subscribed
// after a failed join keeps receiving that room's voice_e2ee_announce relays
// (which carry no channel_id to filter on) for the rest of the connection,
// polluting whatever voice session the client joins next.
//
// This reuses voiceJoinPostTokenRaceHook (test-only plumbing shared with
// OC-0008 and OC-0172) to fault-inject a GetChannelVoiceStates failure inside
// voiceJoinComplete, landing strictly after h.pubsub.Subscribe has already
// run for this join.
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/owncord/server/auth"
"github.com/owncord/server/config"
"github.com/owncord/server/db"
)
// TestVoiceJoin_GetChannelVoiceStatesError_UnsubscribesVoiceTopic pins
// OC-0219: rollbackVoiceJoin must drop the client's VoiceTopic subscription,
// not just its in-memory voiceChID, so a socket that failed mid-join stops
// receiving that room's E2EE relays.
func TestVoiceJoin_GetChannelVoiceStatesError_UnsubscribesVoiceTopic(t *testing.T) {
database := newHarvestVoiceDB(t)
uid := seedHarvestVoiceUser(t, database, "join-0219-victim")
chID := mustCreateVoiceChannel(t, database, "voice-join-0219")
lk, err := NewLiveKitClient(&config.VoiceConfig{
LiveKitAPIKey: "test-api-key-0219",
LiveKitAPISecret: "test-api-secret-0219-xyz",
LiveKitURL: "ws://127.0.0.1:1", // never dialed: GenerateToken is local
})
if err != nil {
t.Fatalf("NewLiveKitClient: %v", err)
}
h := NewHub(database, auth.NewRateLimiter(), nil)
h.SetLiveKit(lk)
send := make(chan []byte, 8)
c := NewTestClient(h, uid, send)
c.user = &db.User{ID: uid, Username: "join-0219-victim"}
h.mu.Lock()
h.clients[uid] = c
h.mu.Unlock()
// Fault-inject the GetChannelVoiceStates call inside voiceJoinComplete —
// same technique as the OC-0172 regression test. This hook fires after
// GenerateToken succeeds and strictly before voiceJoinComplete's
// h.pubsub.Subscribe call runs, so by the time GetChannelVoiceStates
// executes the client is already subscribed to VoiceTopic(chID).
var hookRan bool
voiceJoinPostTokenRaceHook = func(client *Client) {
hookRan = true
if _, err := database.ExecContext(context.Background(), `ALTER TABLE users RENAME TO users_bak_0219`); err != nil {
t.Fatalf("hook: rename users: %v", err)
}
}
defer func() { voiceJoinPostTokenRaceHook = nil }()
payload, _ := json.Marshal(map[string]any{"channel_id": chID})
h.handleVoiceJoin(context.Background(), c, json.RawMessage(payload))
if !hookRan {
t.Fatal("voiceJoinPostTokenRaceHook never fired — test setup is broken, not exercising the join path")
}
drainChan(send, 200*time.Millisecond)
// Sanity: the in-memory voiceChID was rolled back (OC-0172 already pins
// this half of the cleanup).
if gotCh := c.getVoiceChID(); gotCh != 0 {
t.Fatalf("client voiceChID = %d after GetChannelVoiceStates failed mid-join, want 0 (rolled back)", gotCh)
}
// The bug: rollbackVoiceJoin must also drop the VoiceTopic subscription
// that voiceJoinComplete already established. Left in place, this socket
// keeps receiving voice_e2ee_announce relays for chID indefinitely.
topic := VoiceTopic(chID)
for _, tp := range h.pubsub.TopicsForClient(uid) {
if tp == topic {
t.Fatalf("client is still subscribed to %q after rollbackVoiceJoin — E2EE relays for this channel will keep reaching a socket that never finished joining it", topic)
}
}
}
@@ -0,0 +1,145 @@
package ws
// oc_0222_reconnect_status_order_test.go — regression test for OC-0222.
//
// handleReconnect wrote the resume handshake's auth_ok (reconnectWriteReplay,
// which reads c.user.Status) BEFORE calling applyConnectStatus, which is what
// settles c.user.Status via db.ConnectStatus(saved) and persists it. So a
// resumed auth_ok always carried the disconnect-time status rather than the
// status the session is about to come online as.
//
// Concretely: MarkUserDisconnected rewrites a plain "online" user to
// "offline" on socket loss. On a fast reconnect (still covered by the ring
// buffer, so the buffer-tier replay path is taken) the resumed auth_ok's
// payload.user.status must reflect db.ConnectStatus("offline") == "online" —
// matching what applyConnectStatus is about to write and broadcast — not the
// raw "offline" row value read moments earlier by refreshUserSnapshot.
//
// handleFreshConnect already gets this right: it calls applyConnectStatus
// before building auth_ok (serve.go, handleFreshConnect). This test locks the
// same ordering for the resume path.
import (
"context"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/owncord/server/auth"
"github.com/owncord/server/db"
)
func TestReconnect_AuthOKReflectsSettledStatus_NotDisconnectTimeStatus(t *testing.T) {
database := newTeardownTestDB(t)
ctx := context.Background()
userID, err := database.CreateUser(ctx, "resume-status-user", "hash", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
// Establish the user as a plain "online" session, then simulate the
// socket loss that precedes every reconnect: MarkUserDisconnected only
// rewrites a plain "online" row to "offline" (idle/dnd/invisible survive
// untouched), so this is the ordinary case, not a contrived one.
if err := database.UpdateUserStatus(ctx, userID, db.StatusOnline); err != nil {
t.Fatalf("UpdateUserStatus(online): %v", err)
}
if err := database.MarkUserDisconnected(ctx, userID); err != nil {
t.Fatalf("MarkUserDisconnected: %v", err)
}
pre, err := database.GetUserByID(ctx, userID)
if err != nil || pre == nil {
t.Fatalf("GetUserByID (precondition): %v", err)
}
if pre.Status != db.StatusOffline {
t.Fatalf("precondition: expected status=offline after MarkUserDisconnected, got %q", pre.Status)
}
token, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if _, err := database.CreateSession(ctx, userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
hub := NewHub(database, auth.NewRateLimiter(), nil)
go hub.Run()
defer hub.Stop()
// Global (channel_id 0) frames bracketing last_seq=99, so the resume takes
// the buffer tier rather than falling through to a full ready (which also
// sends auth_ok, but via handleFreshConnect's already-correct ordering —
// asserting on that path would not exercise the bug).
rb := hub.ReplayBuffer()
rb.Push(98, 0, []byte(`{"seq":98,"type":"presence","payload":{}}`))
rb.Push(99, 0, []byte(`{"seq":99,"type":"presence","payload":{}}`))
rb.Push(100, 0, []byte(`{"seq":100,"type":"presence","payload":{}}`))
srv := httptest.NewServer(ServeWS(hub, database, []string{"*"}, 0))
defer srv.Close()
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
conn, dialResp, dialErr := websocket.Dial(dialCtx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil)
if dialResp != nil && dialResp.Body != nil {
_ = dialResp.Body.Close()
}
if dialErr != nil {
t.Fatalf("websocket.Dial: %v", dialErr)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
raw, _ := json.Marshal(map[string]any{
"type": "auth",
"payload": map[string]any{
"token": token,
"last_seq": uint64(99),
},
})
if err := conn.Write(dialCtx, websocket.MessageText, raw); err != nil {
t.Fatalf("write auth: %v", err)
}
readCtx, readCancel := context.WithTimeout(ctx, 5*time.Second)
defer readCancel()
_, msg, err := conn.Read(readCtx)
if err != nil {
t.Fatalf("read handshake response: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(msg, &parsed); err != nil {
t.Fatalf("unmarshal handshake response: %v; raw=%s", err, msg)
}
if parsed["type"] != MsgTypeAuthOK {
t.Fatalf("expected auth_ok (buffer-tier resume), got %v; raw=%s", parsed["type"], msg)
}
payload, _ := parsed["payload"].(map[string]any)
if payload["replay_source"] != "buffer" {
t.Fatalf("expected replay_source=buffer (so this exercises handleReconnect, not the fresh-connect fallback), got %v", payload["replay_source"])
}
userField, _ := payload["user"].(map[string]any)
gotStatus, _ := userField["status"].(string)
if gotStatus != db.StatusOnline {
t.Fatalf("resumed auth_ok payload.user.status = %q, want %q (db.ConnectStatus of the pre-reconnect \"offline\" row) — "+
"the resumed auth_ok must carry the status the session is settling on, not the stale disconnect-time row value",
gotStatus, db.StatusOnline)
}
// The persisted row must agree with what auth_ok claimed — applyConnectStatus
// must have actually run and been visible before/at the point auth_ok was
// built, not merely be about to run after the client already parsed the
// (wrong) value.
post, err := database.GetUserByID(ctx, userID)
if err != nil || post == nil {
t.Fatalf("GetUserByID (postcondition): %v", err)
}
if post.Status != db.StatusOnline {
t.Fatalf("persisted status after reconnect = %q, want %q", post.Status, db.StatusOnline)
}
}
@@ -0,0 +1,64 @@
package ws
// Internal test for OC-0237: serviceErrorToResult's default branch (the one
// hit for service.ErrInternal, since ErrInternal has no dedicated case above
// it) put err.Error() straight into the ClientError sent to the requesting
// client, and never logged anything server-side. Service-layer ErrInternal
// wrappers embed the underlying driver error via %v (see Server/service/dm.go),
// so this leaked internal query names and driver state to an ordinary member,
// while producing zero server-side log output — handlers.go only logs when
// result.Error is NOT a ClientError. The REST twin, writeServiceError in
// Server/api/channel_handler.go, does the opposite: it logs the error and
// replies with the fixed string "an internal error occurred".
import (
"bytes"
"errors"
"fmt"
"log/slog"
"strings"
"testing"
"github.com/owncord/server/service"
)
func TestServiceErrorToResult_InternalErrorDoesNotLeakAndIsLogged(t *testing.T) {
prev := slog.Default()
var buf bytes.Buffer
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})))
t.Cleanup(func() { slog.SetDefault(prev) })
// Mirrors Server/service/dm.go:149 — a real ErrInternal wrapper embedding
// driver error text via %v, exactly what handlers_call.go's RingTargets
// call produces when GetDMParticipantIDs fails.
driverErr := errors.New("GetDMParticipantIDs: database is locked")
svcErr := fmt.Errorf("%w: failed to read DM participants: %v", service.ErrInternal, driverErr)
result := serviceErrorToResult(svcErr)
ce, ok := result.Error.(ClientError)
if !ok {
t.Fatalf("serviceErrorToResult(ErrInternal wrapper) did not return a ClientError, got %T", result.Error)
}
if ce.Code != ErrCodeInternal {
t.Fatalf("ClientError.Code = %q, want %q", ce.Code, ErrCodeInternal)
}
// The client-facing message must not leak driver/query internals — it
// must match every other ErrCodeInternal site in this package, which all
// use a fixed string (deps.go, registry.go, voice_controls.go, serve.go).
if strings.Contains(ce.Message, "database is locked") || strings.Contains(ce.Message, "GetDMParticipantIDs") {
t.Fatalf("ClientError.Message leaked internal error detail to the client: %q", ce.Message)
}
if ce.Message == svcErr.Error() {
t.Fatalf("ClientError.Message is the raw wrapped service error verbatim: %q", ce.Message)
}
// Unlike the REST path (writeServiceError), and unlike this same handler
// path for every other error class, nothing was ever written to the
// server log for an internal error — the operator had no record the
// failure happened at all.
if !strings.Contains(buf.String(), "database is locked") {
t.Fatalf("serviceErrorToResult did not log the internal error server-side; log output: %q", buf.String())
}
}
+10
View File
@@ -133,3 +133,13 @@ func (rb *EventRingBuffer) OldestSeq() uint64 {
oldestIdx := (rb.pos - rb.count + rb.size) % rb.size
return rb.entries[oldestIdx].seq
}
// NewestSeq returns the highest sequence number in the buffer, or 0 if empty.
func (rb *EventRingBuffer) NewestSeq() uint64 {
rb.mu.RLock()
defer rb.mu.RUnlock()
if rb.count == 0 {
return 0
}
return rb.newestSeqLocked()
}
+9 -2
View File
@@ -277,6 +277,15 @@ func (h *Hub) handleReconnect(
events = append(events, h.liveVoiceEventsSince(ctx, lastSeq, liveVoiceChID)...)
}
// Settle the session's status BEFORE the auth_ok write below, mirroring
// handleFreshConnect's ordering: reconnectWriteReplay reads c.user.Status
// to build auth_ok, so if this ran after that write the resumed client
// would be told its disconnect-time status (routinely "offline", since
// MarkUserDisconnected just rewrote it) instead of the status it is about
// to come online as and broadcast (OC-0222). Skips member_join — the user
// was already known.
applyConnectStatus(ctx, database, c)
if !h.reconnectWriteReplay(ctx, conn, c, lastSeq, events, replaySource) {
// startPumps=false: the teardown inside reconnectWriteReplay already ran
// in full. Starting readPump on this closed conn would hit an immediate
@@ -285,8 +294,6 @@ func (h *Hub) handleReconnect(
return true, false
}
// Update presence but skip member_join — user was already known.
applyConnectStatus(ctx, database, c)
h.announceConnectPresence(c)
return true, true
+16 -10
View File
@@ -58,13 +58,17 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db
hash := auth.HashToken(p.Token)
sess, err := database.GetSessionByTokenHash(ctx, hash)
if err != nil || sess == nil {
if err != nil {
// DB outage, not a bad token — send a non-terminal error frame so the
// client's normal backoff/reconnect logic retries instead of treating
// this like a genuinely invalid session (buildAuthError is defined as
// non-recoverable on the wire: the client stops reconnecting and
// clears its stored credentials on that frame).
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, "temporary failure, please retry"))
return nil, "", resumeHint{}, fmt.Errorf("auth: session lookup failed: %w", err)
}
if sess == nil {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid token"))
if err != nil {
// DB outage, not a bad token — carry the cause so the caller's log
// distinguishes it from an ordinary invalid-token rejection.
return nil, "", resumeHint{}, fmt.Errorf("auth: session lookup failed: %w", err)
}
return nil, "", resumeHint{}, fmt.Errorf("auth: invalid session")
}
@@ -74,11 +78,13 @@ func authenticateConn(parent context.Context, conn *websocket.Conn, database *db
}
user, err := database.GetUserByID(ctx, sess.UserID)
if err != nil || user == nil {
if err != nil {
// Same DB-outage-vs-bad-credential distinction as above.
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, "temporary failure, please retry"))
return nil, "", resumeHint{}, fmt.Errorf("auth: user lookup failed: %w", err)
}
if user == nil {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("user not found"))
if err != nil {
return nil, "", resumeHint{}, fmt.Errorf("auth: user lookup failed: %w", err)
}
return nil, "", resumeHint{}, fmt.Errorf("auth: user not found")
}
+10 -1
View File
@@ -640,7 +640,16 @@ func handleVoiceTokenRefreshV2(ctx context.Context, cmd Command, info ClientInfo
// the row back far enough to learn it), the row is re-read here and the
// delete is skipped unless it still names channelID.
func (h *Hub) rollbackVoiceJoin(ctx context.Context, c *Client, channelID int64, joinedAt string, broadcast bool) {
c.clearVoiceChID()
// OC-0219: use clearVoiceAndUnsubscribe (not the bare clearVoiceChID) so a
// join that already reached voiceJoinComplete's h.pubsub.Subscribe call
// drops its VoiceTopic subscription along with its in-memory voiceChID —
// exactly like every other path that takes a client out of voice while its
// WS stays up (see clearVoiceAndUnsubscribe's doc comment in
// voice_leave.go). Safe for the two earlier call sites too:
// Unsubscribe is a documented no-op when the client was never subscribed
// to that topic (pubsub.go), which is the case whenever this fires before
// voiceJoinComplete's Subscribe has run.
h.clearVoiceAndUnsubscribe(c)
// The client's voice state is now set before token generation (BUG-088),
// so a concurrent join/leave in the same channel can have elected this
// half-joined client key holder. Re-run the election after taking it back
+83
View File
@@ -281,6 +281,89 @@ func TestAuthenticateConn_InvalidToken_ReceivesAuthError(t *testing.T) {
}
}
// TestAuthenticateConn_SessionLookupDBError_NotTerminal verifies OC-0196: a
// transient DB error while looking up the session (GetSessionByTokenHash
// returning a genuine error rather than sql.ErrNoRows) must NOT be reported
// as the terminal auth_error frame. The client treats auth_error as
// non-recoverable — it stops reconnecting and clears the user's stored
// credentials (see Client/tauri-client/src/lib/ws.ts and dispatcher.ts) — so
// collapsing "DB unreachable" into "bad token" force-logs-out every client
// that reconnects during a sub-second SQLite hiccup even though its session
// row is perfectly valid. A DB error must surface as a non-terminal error
// frame instead, so the client's normal backoff/reconnect logic retries.
func TestAuthenticateConn_SessionLookupDBError_NotTerminal(t *testing.T) {
database := openServeTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter, nil)
go hub.Run()
defer hub.Stop()
userID, err := database.CreateUser(context.Background(), "db-hiccup-user", "hash", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
token, err := auth.GenerateToken()
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
if _, err := database.CreateSession(context.Background(), userID, auth.HashToken(token), "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
handler := ws.ServeWS(hub, database, []string{"*"}, 0)
srv := httptest.NewServer(handler)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, resp, err := websocket.Dial(ctx, wsURL, nil)
if resp != nil && resp.Body != nil {
defer resp.Body.Close()
}
if err != nil {
t.Fatalf("websocket.Dial: %v", err)
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
// Simulate a transient DB outage AFTER the session/token above were
// written successfully: close the database so the next query
// (GetSessionByTokenHash, made when the auth frame below is processed)
// returns a genuine driver error instead of (nil, nil). The session row
// itself remains logically valid — this models momentary SQLite reader
// contention (WAL checkpoint, backup, busy_timeout), not a bad token.
if err := database.Close(); err != nil {
t.Fatalf("database.Close: %v", err)
}
authMsg := map[string]any{
"type": "auth",
"payload": map[string]string{"token": token},
}
raw, _ := json.Marshal(authMsg)
if err := conn.Write(ctx, websocket.MessageText, raw); err != nil {
t.Fatalf("write: %v", err)
}
_, respRaw, readErr := conn.Read(ctx)
if readErr != nil {
t.Fatalf("read: %v", readErr)
}
var msg map[string]any
if err := json.Unmarshal(respRaw, &msg); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if msg["type"] == ws.MsgTypeAuthError {
t.Errorf("got terminal %q frame for a transient DB error — the client "+
"treats this as non-recoverable and clears stored credentials; a DB "+
"hiccup must surface as a retryable error instead", ws.MsgTypeAuthError)
}
if msg["type"] != ws.MsgTypeError {
t.Errorf("response type = %q, want %q (non-terminal error frame)", msg["type"], ws.MsgTypeError)
}
}
// TestServeWS_ValidAuth_FullHandshake verifies the complete happy path:
// valid token → auth_ok + ready received, client counted in hub.
func TestServeWS_ValidAuth_FullHandshake(t *testing.T) {
+5
View File
@@ -995,6 +995,11 @@ Create or retrieve a 1-on-1 DM channel with another user. If a DM channel alread
}
```
On a newly created channel (`201`, `"created": true`) the recipient also
receives a `dm_channel_open`. Re-opening an existing DM (`200`) emits nothing —
it only touches the caller's own open state. The creator is not sent the event
on either path; it learns the channel from the response body above.
---
### GET /api/v1/dms
+1 -1
View File
@@ -144,7 +144,7 @@ sequenceDiagram
P->>API: POST /dms {recipient_id}
API-->>P: DM channel
P->>DM: open DM mode + focus channel
Note over U,DM: server also broadcasts dm_channel_open to both parties
Note over U,DM: on a newly created DM the server sends dm_channel_open to the recipient
```
### 3.1a Group DMs