mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-02 19:43:10 +03:00
fix: 40 correctness fixes from the 2026-08-19 bug hunt (#1392)
* fix(identity): 1 defect(s) (OC-0151)
* fix(ws): 1 defect(s) (OC-0152)
* fix(admin): 1 defect(s) (OC-0153)
* fix(admin): 1 defect(s) (OC-0154)
* fix(voice): 2 defect(s) (OC-0155, OC-0167)
Replace distributeRoomKey's per-call offer counter with an instance-level
sliding-window budget shared by every voice_e2ee_offer send path.
- OC-0155: back-to-back rotations (the second run immediately by
drainPendingRotationOrArmTimer) each got a fresh pacing budget, so their
combined sends could exceed the server's single per-second cap.
- OC-0167: handleAnnounceInner's drain-time offer send bypassed pacing
entirely, letting a key holder joining a large ongoing call burst every
queued announce's offer unpaced.
The shared budget is reset in clearState() since the server's limit is
scoped per (sender, channel).
* fix(client): 1 defect(s) (OC-0156)
createPresenceSender dropped a queued custom_status when a later plain
status change superseded the pending retry. The retry now carries the
last committed custom_status forward.
* fix(client): 2 defect(s) (OC-0160, OC-0163)
OC-0160: exempt the handshake frames (ready, auth_ok) from the ws message
size limit and run the guard after parsing. A 'ready' frame grows unbounded
with member/channel/DM counts and carries no seq, so dropping it left the
client on empty stores with no error and no recovery path.
OC-0163: bracket a bare IPv6 host when building the wss:// URL so the
authority parses, and collapse bracketed/bare IPv6 literals to the same
cert_store_key so one server is not pinned (and user-confirmed) twice.
* fix(voice): 1 defect(s) (OC-0162)
updatePttKey armed the Rust poller when a PTT key was bound mid-call but
never applied the gate. The poller only emits 'ptt-state' on a press/release
transition, so an idle key produced no event and the already-published mic
stayed hot until the user's first physical press+release. Mirror the join-time
gate computation in updatePttKey, guarded on being in a call, polling actually
being live, and the mic not already being gated.
* fix(client): 1 defect(s) (OC-0164)
* fix(plugin): 1 defect(s) (OC-0165)
scanPluginDirectory now skips a malformed plugin subdirectory and joins its
error instead of aborting the whole scan, and LoadAll logs-and-continues so
one bad plugin directory cannot disable every other plugin.
* fix(ws): 1 defect(s) (OC-0166)
Route PresenceSelfEvent onto the owner's normal-priority queue instead of
letting it fall through to the UserTargetedEvent high-priority case, so a
user's own presence frames all share one FIFO and cannot be delivered out
of order relative to the visible presence_update path.
* fix(db): 1 defect(s) (OC-0168)
* fix(client): 1 defect(s) (OC-0169)
* fix(client): 1 defect(s) (OC-0171)
addMessage appended a broadcast at the tail even when trailing optimistic
rows were still unreconciled, so a message that committed while our own
send was in flight ended up ordered behind the row confirmSend later
stamped with a higher server id/timestamp. Insert before the trailing
unreconciled run instead.
* fix(voice): 1 defect(s) (OC-0172)
* fix(client): 1 defect(s) (OC-0174)
* fix(ws): 1 defect(s) (OC-0175)
* fix(client): 1 defect(s) (OC-0177)
* fix(client): 1 defect(s) (OC-0178)
* fix(voice): 1 defect(s) (OC-0179)
Undeafening no longer sends a voice_mute{muted:false} the server will
refuse while a moderator-imposed mute stands, matching the localServerMuted
guard already present in onMuteToggle.
* fix(client): 1 defect(s) (OC-0182)
* fix(plugin): 1 defect(s) (OC-0183)
* fix(client): 1 defect(s) (OC-0184)
Treat a trailing underscore as an emphasis delimiter, not part of the URL,
when scanning for the end of an autolinked URL.
* fix(client): 1 defect(s) (OC-0185)
Reveal .msg-actions-bar on .message:focus-within, not only on hover, so
keyboard users can see the per-message action buttons they Tab into
instead of activating them at opacity: 0.
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): 1 defect(s) (OC-0186)
* fix(client): 1 defect(s) (OC-0187)
The Add Server modal validated addresses with its own narrower regex that
never gained IPv6 support when api.ts's validator did, so an IPv6 server
could be logged into but never saved as a profile. Extract the validator
into src/lib/hostValidation.ts and use it from both call sites.
* fix(client): 1 defect(s) (OC-0189)
DM sidebar rows dropped mention counts entirely and the header total
excluded muted conversations outright, so a direct mention in a muted DM
was invisible. Render a mention badge that outranks the plain unread
badge, and count a muted channel's mentionCount toward the header total.
* fix(client): 1 defect(s) (OC-0190)
* fix(client): 1 defect(s) (OC-0191)
* fix(client): 2 defect(s) (OC-0157, OC-0176)
* fix(client): 1 defect(s) (OC-0161)
confirmTotp answers 401 for a wrong enrollment code while the session is still valid; firing the global onUnauthorized sink signed the user out and deleted their stored credential. Opt that one call out via a skipUnauthorized flag on doFetch.
* fix(admin): 1 defect(s) (OC-0173)
* fix(identity): 1 defect(s) (OC-0180)
* fix(admin): archived channel PATCH skips voice eviction and fan-out (OC-0158)
handlePatchChannel commits the AdminUpdateChannel write, then re-reads the
channel to drive voice eviction and the visibility fan-out. When that
post-commit re-read failed, the handler returned early: the archive was
durable but connected clients were never told and voice members were never
evicted, leaving users talking in a channel that no longer exists for them.
Drive the post-commit work off the values already in hand rather than
abandoning it when the re-read fails.
Adds SetPatchChannelPostCommitHook so the test can land a cancellation in
that exact window deterministically instead of racing wall-clock timing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(admin): role changes commit with no client ever notified (OC-0170)
broadcastRoles derived its context from the inbound *http.Request, so the
roles_update fan-out was tied to the request lifetime. A role create,
update, or delete could commit to the database and then broadcast nothing
once that request context was done, leaving every connected client on a
stale role list until the next full resync.
Decouple the fan-out from the request context so the broadcast follows the
commit rather than the caller.
Adds BroadcastRolesForTest to reach broadcastRoles from the external test
package.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): username rename stomps the profile card header (OC-0188)
The account profile card's header is a resolveDisplayName() slot, but the
username-rename save path wrote the raw username straight into it. A user
with a display name set would see the header switch from their display
name to their new username after a rename, disagreeing with every other
surface that renders the same identity.
Resolve the header through the same display-name path the initial render
uses, so a rename updates the username field without touching the header.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* fix(client): settings overlay never focuses when mounted already-open (OC-0181)
mount() synced initial state — including the show() that calls
focusDialog() — before appending root to the container. .focus() on a
still-detached subtree is a silent no-op, so a caller that mounts while
uiStore.settingsOpen is already true (ConnectPage's lazy first-open path)
got a visible overlay whose focus trap never captured focus: keyboard
users landed outside the dialog with Tab escaping to the page behind it.
Attach root before syncing initial state so focusDialog() runs against a
connected subtree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* chore: satisfy the CI gates for this fix batch
The fix batch's own commits left three CI gates red. Nothing here changes
behaviour; every edit is a lint, type, or formatting correction to code
this batch introduced.
golangci-lint:
- OC-0153 and OC-0173 replaced the last two uses of admin's setupSanitizer,
and OC-0151 the last use of api's sanitizer, leaving both package-level
bluemonday vars unused. Remove them along with the now-unused imports,
and reword the comments that named them so they still explain why the
fixpoint sanitizer is the right one without pointing at deleted symbols.
- Modernize the new handshake-deadline test's loop to range-over-int.
tsc --noEmit:
- jsdom ships no types and @types/jsdom is not a dependency, so declare the
surface the new admin-panel test uses, following src/types/jitsi-rnnoise.d.ts.
- Narrow the last-call lookup instead of indexing under
noUncheckedIndexedAccess, with an explicit failure message.
- membersStore.setState replaces whole state, so the presence-sender mocks
must supply typingUsers.
prettier: reformat the five files this batch touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* chore(ledger): record the 2026-08-19 hunt and its fixes
Adds the 41 findings confirmed by the 2026-08-19 hunt and marks the 40
fixed on this branch, each with its commit, the test that pins it, and
revertProof "pass".
"pass" means an independent check, not the fixing agent's self-report:
every commit had its source diff reverted against the working tree, its
own test re-run and required to FAIL, then the source restored and the
test required to PASS. Commits whose tests live inline in Rust
#[cfg(test)] blocks were proven the same way at hunk level, splicing the
pre-fix source onto the post-fix test module.
OC-0159 is recorded as a duplicate of OC-0152: the flow-reconnect and
flow-message lenses independently found the same unbounded handshake
write and proposed the same helper over the same call sites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
* test(e2e): make the voice-roster join fixture self-consistent
The voice-widget join test emitted a voice_state for user_id 4 claiming
username "newvoiceuser", but id 4 is "member2" in MOCK_MEMBERS_MULTI_ROLE.
A real server never sends a voice_state whose username disagrees with the
member record for that id, and the same file's VOICE_STATE_EVENT already
pairs id 1 with "testuser" correctly — this one event was the outlier.
The contradiction was invisible while the roster rendered the payload's
raw username. OC-0177 makes it resolve identity through membersStore so a
nickname shows the same in voice as everywhere else, at which point the
fixture's own inconsistency surfaced as a failure.
Send id 4's real username and assert on it. The test still covers what it
did before — a genuine join by a user not previously in voice, asserted by
name and by roster count.
Verified against the app unchanged: with the old fixture the spec fails
1/5 (matching CI), with this one it passes 5/5.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6gVN2JM5wrduhkNaFCxdK
---------
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Generated
+1089
-1
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"nextId": 151,
|
||||
"nextId": 192,
|
||||
"findings": [
|
||||
{
|
||||
"id": "OC-0001",
|
||||
@@ -3658,6 +3658,947 @@
|
||||
},
|
||||
"suggestedFix": "Mirror the GlobalKeybinds guard in the two handlers: at OverlayManagers.ts:119 and QuickSwitcher.ts:155 use `if (!(e.ctrlKey || e.metaKey) || e.altKey || e.key.toLowerCase() !== \"k\") return;`. For the suspension gap, add an optional `isSuspended?: () => boolean` to createQuickSwitcherManager and early-return on it, wiring `isSuspended: () => uiStore.getState().settingsOpen` at MainPage.ts:505 so both attach sites read the same source of truth.",
|
||||
"fixedDate": "2026-08-14"
|
||||
},
|
||||
{
|
||||
"id": "OC-0151",
|
||||
"title": "Registration sanitizes the username before bounding it, so a 1 MiB body pins a CPU core for minutes (unauthenticated)",
|
||||
"file": "Server/api/auth_handler.go",
|
||||
"line": 285,
|
||||
"severity": "high",
|
||||
"why": "registerReadRequest runs the fixpoint sanitizer over the raw JSON `username` field *before* auth.ValidateUsername applies the 32-rune cap. service.SanitizeText/sanitizeToFixpoint loops sanitizePass until the string stops changing, and nested HTML entities force one iteration per two nesting levels — so the cost is O(n^2) in the attacker-supplied field length. The sibling login path (loginReadRequest, auth_handler.go:430) bounds the username *first* precisely because it is unvalidated at that point, and service.sanitizeContent (service/message.go:220) bounds `raw` before calling sanitizeToFixpoint for the same reason; register and the profile PATCH are the two call sites that skipped the bound.",
|
||||
"repro": "POST /api/v1/auth/register with body {\"username\":\"&\"+\"amp;\"*262000+\"lt;\", \"password\":\"x\", \"invite_code\":\"x\"} (≈1 MiB, under the 1 MiB MaxBodySizeUnless cap; /api/v1/auth/register is not in bodyCapExemptPrefixes). registerPolicyGate passes on a default server (registration_open defaults true), the body is decoded, and line 285 enters sanitizeToFixpoint. Measured on this tree by calling service.SanitizeText directly: 4 KB → 17 ms, 8 KB → 59 ms, 16 KB → 236 ms, 32 KB → 986 ms, 64 KB → 3.05 s — clean quadratic. Extrapolated to 1 MiB that is ~13 minutes of single-core CPU (plus ~100k allocations of ~1 MiB each) for one request, and the endpoint allows 3 requests/min/IP, so a handful of requests saturates every core indefinitely. No authentication, no invite, and no valid username is required — the work happens before any validation.",
|
||||
"evidence": "Server/api/auth_handler.go:285 req.Username = strings.TrimSpace(service.SanitizeText(req.Username))\nServer/api/auth_handler.go:296 if err := auth.ValidateUsername(req.Username); err != nil { // 32-rune cap, runs AFTER\nServer/service/message.go:195-205 func sanitizeToFixpoint(raw string) string { s := raw; for i := 0; i <= len(raw); i++ { next := sanitizePass(s); if next == s { return next }; s = next } ... }\nServer/service/message.go:166 func sanitizePass(s string) string { return html.UnescapeString(sanitizer.Sanitize(html.UnescapeString(s))) }\ncontrast — Server/api/auth_handler.go:430 if utf8.RuneCountInString(req.Username) > maxLoginUsernameLen { ... } // login bounds first\ncontrast — Server/service/message.go:220 if len(raw) > maxMessageLen*4 { return \"\", ... } // message content bounds first",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "api-authz",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "9de9a6b5",
|
||||
"test": "Server/api/auth_handler_test.go",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Bound the raw field before sanitizing, mirroring sanitizeContent's `len(raw) > maxMessageLen*4` pattern: in registerReadRequest, immediately after the json.Decode and before line 285, reject with 400 when `len(req.Username) > maxLoginUsernameLen*4` (bytes, so it is a cheap pre-check that still admits any legitimate 32-rune UTF-8 name). Apply the identical guard at Server/api/profile_handler.go:186, which is the same call with the same ordering (authenticated, so lower severity but the same amplification).",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0152",
|
||||
"title": "WebSocket handshake writes (auth_ok / ready / replay burst) have no write deadline, so a peer that stops reading pins the handler goroutine and its socket forever",
|
||||
"file": "Server/ws/serve.go",
|
||||
"line": 748,
|
||||
"severity": "high",
|
||||
"why": "Every handshake write uses the bare request context (`ctx := r.Context()` from ServeWS). `websocket.Accept` hijacks the connection, which stops net/http's background read, so that context is only cancelled when the handler returns — i.e. after the write it is supposed to bound. coder/websocket's `Conn.Write` blocks until `ctx.Done()` fires (`setupWriteTimeout` in conn.go), so with a never-firing ctx the write blocks indefinitely once the socket send buffer and the peer's receive window fill. The sibling write path bounds itself: `writePumpWrite` wraps every frame in `context.WithTimeout(ctx, writeTimeout)` (serve_pumps.go:16), and `authenticateConn` bounds its own reads/writes with `authDeadline` (serve_auth.go:27). The `writeTimeout` constant is declared in serve.go:22 and never used in that file.",
|
||||
"repro": "An authenticated client opens /api/v1/ws, sends a valid auth frame with `last_seq` set to a value the ring buffer still covers, and then stops reading from the TCP socket (or advertises a tiny receive window). `reconnectWriteReplay` writes auth_ok plus up to `maxColdReplay` = 5000 replay frames (serve.go:32, 505-527); once the kernel send buffer plus the peer window fill, `conn.Write` blocks with a context that can never be cancelled. The same happens on a fresh connect with a large `ready` payload (serve.go:756) on a server with many members/channels. Consequences: the ServeWS goroutine and the FD are pinned permanently; the client stays in `h.clients` receiving broadcasts until its 256-slot `send` buffer overflows and `closeAllSendLocked` fires, and until `sweepStaleClients` evicts it 90 s later (hub_sweep.go:86) — neither of which unblocks the write or closes the connection. Repeating the connect leaks one goroutine + one FD per attempt without bound, and each attempt is not counted by `maxConns` after the sweep removes it from the hub.",
|
||||
"evidence": "serve.go:21-22 authDeadline = 10 * time.Second / writeTimeout = 10 * time.Second\nserve.go:70 ctx := r.Context()\nserve.go:748 if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, \"none\")); err != nil {\nserve.go:756 if err := conn.Write(ctx, websocket.MessageText, ready); err != nil {\nserve.go:513 if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, replaySource)); err != nil {\nserve.go:520 for _, evt := range events { if err := conn.Write(ctx, websocket.MessageText, evt); err != nil {\n-- vs --\nserve_pumps.go:16 wCtx, cancel := context.WithTimeout(ctx, writeTimeout)",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "flow-message",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "e2fe7cdc",
|
||||
"test": "Server/ws/serve_handshake_write_deadline_test.go",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Add one helper in Server/ws/serve.go and use it for all five handshake writes (serve.go:513, 520, 748, 756, 764) instead of conn.Write(ctx, ...):\n\nfunc handshakeWrite(ctx context.Context, conn *websocket.Conn, msg []byte) error {\n\twCtx, cancel := context.WithTimeout(ctx, writeTimeout)\n\tdefer cancel()\n\treturn conn.Write(wCtx, websocket.MessageText, msg)\n}\n\nThe existing error branches then fire normally: coder/websocket's AfterFunc closes the underlying conn on timeout, the Write returns an error, and unregisterFailedHandshake + conn.Close already run on each of those paths.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0153",
|
||||
"title": "First-run setup stores the Owner's username HTML-escaped while login looks it up raw — the Owner is permanently locked out of their own account",
|
||||
"file": "Server/admin/setup_handler.go",
|
||||
"line": 175,
|
||||
"severity": "high",
|
||||
"why": "setupPrecheck canonicalizes the owner username with a bare `bluemonday.StrictPolicy().Sanitize`, whose text tokens are written through html.EscapeString (' -> ', \" -> ", & -> &, < -> <, > -> >). handleLogin looks the user up with only `strings.TrimSpace(req.Username)` (loginReadRequest -> loginAuthenticate -> GetUserByUsername), so the stored name never matches what the user types. This is the exact defect already fixed on the registration path (auth_handler.go:285) and the profile-rename path (profile_handler.go:186), both of which now use the fixpoint `service.SanitizeText`; the setup path was not converted.",
|
||||
"repro": "Fresh install, no users yet. POST /admin/api/setup with {\"username\":\"O'Brien\",\"password\":\"<strong pw>\"}. auth.ValidateUsername passes (\"O'Brien\" is 11 runes, no control/Cf chars), so users.username is stored as \"O'Brien\" and a 30-day session token is returned, hiding the problem. Now POST /api/v1/auth/login {\"username\":\"O'Brien\",\"password\":\"<same pw>\"} -> GetUserByUsername(\"O'Brien\") returns nil -> 401 \"invalid credentials\", forever, on every device and after the initial session expires. Same for any owner name containing ' \" & < >. Logging in as \"O'Brien\" is the only way in, and nothing tells the operator that.",
|
||||
"evidence": "Server/admin/setup_handler.go:21 var setupSanitizer = bluemonday.StrictPolicy()\nServer/admin/setup_handler.go:175 req.Username = strings.TrimSpace(setupSanitizer.Sanitize(req.Username))\n -> CreateOwnerIfEmpty(r.Context(), req.Username, hash, ownerRoleID) (setup_handler.go:220)\n\nvs. the already-fixed sibling:\nServer/api/auth_handler.go:285 req.Username = strings.TrimSpace(service.SanitizeText(req.Username))\n // \"a plain call here would store a different string than what handleLogin looks up\n // (which only trims), permanently locking out any username containing one of those characters\"\n\nand the lookup side:\nServer/api/auth_handler.go:407 req.Username = strings.TrimSpace(req.Username) // no sanitizer\nServer/api/auth_handler.go:470 user, err := database.GetUserByUsername(r.Context(), req.Username)\n\nbluemonday v1.0.27 sanitize.go:417-443 — case html.TextToken: default: buff.WriteString(token.String()) // x/net/html TextToken.String() == EscapeString(Data)",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "flow-session",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "29619536",
|
||||
"test": "Server/admin/setup_handler_test.go",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "In Server/admin/setup_handler.go:175 use the same fixpoint sanitizer as registration: `req.Username = strings.TrimSpace(service.SanitizeText(req.Username))` (Server/service/message.go:214). Server/admin already imports github.com/owncord/server/service (admin.go:12) and service does not import admin, so there is no cycle. One line, in the one place setup canonicalizes the username.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0154",
|
||||
"title": "Admin \"Can access\" toggle is silently reverted by the override matrix in the same Save — channel stays public",
|
||||
"file": "Server/admin/static/index.html",
|
||||
"line": 1175,
|
||||
"severity": "high",
|
||||
"why": "saveChannelPerms writes the quick \"Can access\" toggles first (PUT allow=0/deny=0x202) and then writes the override matrix. The matrix masks are collected from radios rendered from the *pre-save* snapshot (state.permChannel.roles), so when the matrix target is the very role just hidden, collectOverrideMasks() returns (0,0) and the handler DELETEs the override row it wrote one line earlier. The operator is told \"Channel permissions updated\" and the channel is still visible to that role.",
|
||||
"repro": "Admin panel → Channels → lock icon on #general. Role \"Member\" has no override (allow=0, deny=0). In the \"Override matrix\" dropdown pick \"Member\" (every radio renders as Inherit). Then untick \"Can access\" next to Member. Click Save. Request 1: PUT /admin/api/channels/{id}/permissions/{memberRoleId} {allow:0,deny:514}. Request 2: masks.allow===0 && masks.deny===0 → DELETE /admin/api/channels/{id}/permissions/{memberRoleId}. Net override state: none. Toast says \"Channel permissions updated\"; #general remains readable by every Member.",
|
||||
"evidence": "1167: if(!box.checked)await api('PUT','/channels/'+pc.id+'/permissions/'+role.role_id,{allow:0,deny:DENY_PRIVATE});\n1172: const path=permTargetPath();\n1173: if(path){\n1174: const masks=collectOverrideMasks();\n1175: if(masks.allow===0&&masks.deny===0)await api('DELETE',path);",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "hotspot-server-admin",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "69258a51",
|
||||
"test": "Client/tauri-client/tests/unit/admin-static-channel-perms.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "In saveChannelPerms, record the role IDs the quick-toggle loop actually wrote and skip the matrix step when the selected target is one of them — one guard in the one function: collect `const touched=new Set()` in the loop (add role.role_id on each PUT/DELETE), then wrap the matrix block in `if(path && !(permTargetPath().indexOf('/permissions/')>-1 && touched.has(tid)))`. Cleanest variant: give the quick checkbox an onchange that patches the in-memory role.allow/role.deny in state.permChannel and calls renderPermMatrix(), so the matrix always reflects the pending toggle instead of the stale snapshot.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0155",
|
||||
"title": "Room-key offer pacing budget is per-call, so two back-to-back rotations blow the server's per-second offer cap and strand peers on a dead key",
|
||||
"file": "Client/tauri-client/src/lib/livekitE2EE.ts",
|
||||
"line": 976,
|
||||
"severity": "medium",
|
||||
"why": "`distributeRoomKey` declares `let sentInWindow = 0` as a local, so the OC-0005 pacing budget is reset on every invocation. The server's outer limiter is a *sliding* 1 s window of 64 offers per (sender, channel) (Server/ws/voice_e2ee.go:22 `voiceE2EEOfferRateLimit = 64`, Server/auth/ratelimit.go sliding window), and `drainPendingRotationOrArmTimer` (livekitE2EE.ts:1256-1261) deliberately runs a second rotation *immediately* after the first finishes. Two rotations of N peers therefore issue 2N offers inside one server window; everything past 64 is rejected with RATE_LIMITED, and the client has no handler for that error at all (no `NOT_KEY_HOLDER`/`RATE_LIMITED` branch exists anywhere under Client/tauri-client/src for voice_e2ee_offer), so the drops are silent.",
|
||||
"repro": "Voice channel with 40 participants; I am the key holder and hold 39 peer ECDH keys. (1) Peer X leaves -> handleParticipantLeft takes the `wasKeyHolder && hadPeerKey` branch (livekitE2EE.ts:1177-1191) -> rotateKeyPeriodically -> distributeRoomKey sends 38 offers with no pause (38 < OFFER_RATE_LIMIT_PER_SEC=60). (2) While that loop is awaiting `wrapRoomKey`, peer Y leaves -> `this._rotatingKey` is true, so `_rotationPending = true` (line 1188). (3) Rotation 1 completes -> drainPendingRotationOrArmTimer immediately runs rotateKeyPeriodically again -> distributeRoomKey with `sentInWindow` reset to 0 -> 37 more offers, all within ~1 s of the first batch. (4) Server's sliding window for (me, channel) now sees 75 offers in <1 s; offers 65..75 return ErrCodeRateLimited and are discarded. (5) ~11 peers never receive the epoch-2 room key. I encrypt with epoch-2, they encrypt/decrypt with epoch-1 (livekit key slot 0 is overwritten in place, no key-index versioning), so audio is dead in both directions with those peers until the next periodic rotation fires 5 minutes later. The existing regression test (tests/unit/livekit-e2ee.test.ts:953 \"[OC-0005] paces room-key offers...\") only exercises a single rotation and does not catch this.",
|
||||
"evidence": "livekitE2EE.ts:976-1009\n let sentInWindow = 0;\n for (const [peerId, peerKey] of peers) {\n ...\n if (sentInWindow >= E2EEManager.OFFER_RATE_LIMIT_PER_SEC) { // 60\n await new Promise<void>((resolve) => setTimeout(resolve, E2EEManager.OFFER_RATE_WINDOW_MS));\n sentInWindow = 0;\n ...\n this.deps.getWs()?.send({ type: \"voice_e2ee_offer\", payload: {...} });\n sentInWindow++;\n\nlivekitE2EE.ts:1256-1263\n private async drainPendingRotationOrArmTimer(): Promise<void> {\n if (this._rotationPending) {\n this._rotationPending = false;\n await this.rotateKeyPeriodically(); // second full N-offer burst, sentInWindow back to 0\n return;\n }\n\nServer/ws/voice_e2ee.go:203-206\n ratKey := auth.Key(auth.Key(\"voice_e2ee_offer\", info.UserID), voiceChID)\n if d.Limiter != nil && !d.Limiter.Allow(ratKey, voiceE2EEOfferRateLimit, voiceE2EEWindow) {\n return Result{Error: ClientError{Code: ErrCodeRateLimited, Message: \"too many e2ee offers\"}}\n }",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "voice-e2ee",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "5db10850",
|
||||
"test": "Client/tauri-client/tests/unit/livekit-e2ee.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Make the budget an instance-level sliding window instead of a per-call counter, and funnel every offer send through it. Add a private field `_offerSendTimes: number[] = []` and a single `private async sendOfferPaced(targetUserId, encryptedKey, iv)` that prunes entries older than OFFER_RATE_WINDOW_MS, sleeps until the oldest falls out when `_offerSendTimes.length >= OFFER_RATE_LIMIT_PER_SEC`, then pushes `Date.now()` and does the `getWs()?.send(...)`. Replace distributeRoomKey's local `sentInWindow` bookkeeping (lines 976, 984-996, 1006) with a call to it; reset `_offerSendTimes.length = 0` in clearState(). One shared gate covers the rotation, become-holder, and H3 paths at once.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0156",
|
||||
"title": "A queued custom-status presence_update loses its custom_status when a later plain status change supersedes it",
|
||||
"file": "Client/tauri-client/src/lib/presence.ts",
|
||||
"line": 71,
|
||||
"severity": "medium",
|
||||
"why": "createPresenceSender's send() clears any pending retry and re-arms it carrying only its own `customStatus` argument. Every producer except the custom-status commit calls send(status) with customStatus undefined, so a status change landing inside the shared 1-per-10s limiter window replaces a queued frame that carried custom_status with one that omits it. The text was already applied locally (updatePresence at line 52 plus saveCustomStatus in the UserBar), and nothing ever re-sends it — restoreSavedPresence (MainPage.ts:190) only re-sends `status` — so the client and the server disagree about the user's custom status for the rest of the install's life.",
|
||||
"repro": "Preconditions: signed in, socket connected, presence limiter is createPresenceLimiter() = 1 token / 10 000 ms.\n\nt=0s Click the UserBar avatar dot -> click \"Idle\".\n UserBar.ts:174 -> presenceSender.send(\"idle\"). limiter.tryConsume() succeeds, {status:\"idle\"} goes out. The window is now closed until t=10s.\n\nt=2s In the same open dropdown, type \"Working on OwnCord\" into the custom-status input and press Enter.\n UserBar.ts:185 -> saveCustomStatus(text); presenceSender.send(\"idle\", \"Working on OwnCord\").\n updatePresence writes the text into membersStore. tryConsume() fails, so\n retry = setTimeout(() => send(loadUserStatus(), \"Working on OwnCord\"), ~8000).\n\nt=4s Click \"Do Not Disturb\" in the same dropdown.\n UserBar.ts:174 -> presenceSender.send(\"dnd\"), customStatus === undefined.\n presence.ts:54-57 clears the t=2s timer; presence.ts:71 re-arms it as\n send(loadUserStatus(), undefined).\n\nt=10s The retry fires and emits ws.send({type:\"presence_update\", payload:{status:\"dnd\"}}) — no custom_status key.\n\nObserved: the server's users.custom_status still holds whatever it held before t=2s; every other client's member list and profile popup show the old (or empty) text. Locally the picker input still reads \"Working on OwnCord\" (loadCustomStatus() from localStorage) and membersStore's self row still carries it, so the user has no signal anything was lost. A later reconnect's `ready` restates the server value into membersStore but the picker input keeps showing the localStorage copy, and restoreSavedPresence (MainPage.ts:190-195) only ever re-sends `status`, so the text is never retransmitted.\n\nExpected: the retry should carry the most recent custom_status the user committed (or the send should not silently drop a field the superseding call simply did not mention).\n\nNot test-locked: tests/unit/status-picker-userbar.test.ts:129 exercises only the queue-and-retry of `status`; no test asserts anything about custom_status surviving supersession.",
|
||||
"evidence": "presence.ts:49-73\n function send(status: UserStatus, customStatus?: string): void {\n const userId = authStore.getState().user?.id ?? 0;\n if (userId !== 0) {\n updatePresence(userId, status, customStatus); // local optimistic write happens regardless\n }\n if (retry !== null) {\n clearTimeout(retry); // <-- discards the queued frame's custom_status\n retry = null;\n }\n if (limiter.tryConsume()) {\n if (customStatus === undefined) {\n ws.send({ type: \"presence_update\", payload: { status } }); // no custom_status field\n } else {\n ws.send({ type: \"presence_update\", payload: { status, custom_status: customStatus } });\n }\n } else {\n retry = setTimeout(() => {\n retry = null;\n send(loadUserStatus(), customStatus); // status is re-read live; customStatus is the *stale* argument\n }, limiter.getRemainingMs());\n }\n }\n\nBoth producers share one PresenceSender (MainPage.ts:133 `const presenceSender = createPresenceSender(ws, limiters.presence);`) and live in the same dropdown:\n components/UserBar.ts:174 onStatusChange: sender.send(status); // customStatus === undefined\n components/UserBar.ts:185 onCustomStatusChange: sender.send(loadUserStatus(), text);",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "client-state",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "7243d1cb",
|
||||
"test": "Client/tauri-client/tests/unit/presence-sender.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Carry the pending custom status across supersession inside createPresenceSender (one guard in the shared function, no caller changes). Track it in the closure: add `let pendingCustom: string | undefined;` next to `retry`; at the top of send(), do `const effective = customStatus !== undefined ? customStatus : (retry !== null ? pendingCustom : undefined);` then use `effective` for both the updatePresence call and the ws.send branch; when queuing set `pendingCustom = effective;` and when actually sending (or in destroy()) set `pendingCustom = undefined;`. A plain status change then no longer drops a custom_status the user already committed but that has not yet reached the server.",
|
||||
"coordNote": "Coordinates corrected by the session: the finder cited a stale numbering (file has 84 lines); the verifier read the working tree (send() at 49-73) and its coordinates were used.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0157",
|
||||
"title": "A session that ends between auth_ok and ready never tears down the ConnectedOverlay, leaving an opaque full-screen cover over the connect page forever",
|
||||
"file": "Client/tauri-client/src/main.ts",
|
||||
"line": 763,
|
||||
"severity": "medium",
|
||||
"why": "The ConnectedOverlay is created and appended to #app inside the `auth_ok` handler (main.ts:417-428) while the router is still on \"connect\", and its only teardown paths are the overlay's own `onReady` timer, the next `wirePostAuth`, `onAutoLoginCancel`, and the invite deep-link handler. The logout/disconnect subscriber — the one path a mid-handshake ban, auth_error or server shutdown actually takes — is gated on `router.getCurrentPage() === \"main\"` and does not call `connectedOverlay.destroy()` at all, so the overlay is orphaned in the DOM with no remaining owner.",
|
||||
"repro": "1) Log in (or auto-login) to a server. `auth_ok` arrives, main.ts:417-428 mounts the ConnectedOverlay over #app and shows it; the router is still on \"connect\" (it only moves to \"main\" from the overlay's own onReady, 800 ms after `ready`).\n2) Before `ready` arrives, an admin bans the account. The server sends `{\"type\":\"error\",\"payload\":{\"code\":\"BANNED\"}}`; dispatcher.ts:1057-1058 runs `ws.disconnect(); clearAuth();`. (Same outcome for `auth_error` on an intervening reconnect — dispatcher.ts:261 — and for `server_restart` reason \"shutdown\" — dispatcher.ts:995.)\n3) `clearAuth()` flips isAuthenticated; the subscriber at main.ts:763 sees `getCurrentPage() === \"connect\"` and does nothing at all, so `connectedOverlay` is never destroyed and `router.navigate(\"connect\")` (main.ts:795) is never reached either.\n4) `ws.disconnect()` set `intentionalClose` and tore down the Tauri listeners, so `ready` can never arrive: `unsubReady` never fires, `markReady()` never runs, the 800 ms `onReady` timer is never armed, and `destroy()` is never called.\n5) The `.connected-overlay.visible` element (position:fixed, inset:0, opaque `--bg-primary`, z-index 200) stays over the connect page permanently — the login form and the \"You have been banned\" transient error are both unreachable. Only restarting the app clears it.\nVariant with a wider window: if the BANNED/shutdown frame lands *after* `ready` but inside the 800 ms `READY_DELAY_MS` window, the already-armed timer still fires `onReady()` → `router.navigate(\"main\")`, mounting MainPage on a cleared authStore and a disconnected socket (the isAuthenticated subscriber has already run and will not fire again).",
|
||||
"evidence": "main.ts:417 connectedOverlay = createConnectedOverlay({ ... onReady: () => { connectedOverlay?.destroy(); connectedOverlay = null; router.navigate(\"main\"); } });\nmain.ts:427 appEl!.appendChild(connectedOverlay.element);\nmain.ts:428 connectedOverlay.show();\nmain.ts:430 const unsubReady = ws.on(\"ready\", () => { unsubReady(); connectedOverlay?.markReady(); });\n\nmain.ts:763 if (!isAuthenticated && router.getCurrentPage() === \"main\") {\nmain.ts:776 dispatcherCleanup?.();\nmain.ts:778 sessionCleanup?.();\nmain.ts:780 ws.disconnect();\n // ...no connectedOverlay?.destroy() anywhere in this block\nmain.ts:795 router.navigate(\"connect\");\nmain.ts:796 }\n\nstyles/login.css:1427 .connected-overlay { position: fixed; inset: 0; background: var(--bg-primary); display: none; ... z-index: 200; }\nstyles/login.css:1438 .connected-overlay.visible { display: flex; }\n\nlib/dispatcher.ts:1046 if (payload.code === \"BANNED\") {\nlib/dispatcher.ts:1056 setTransientError(payload.message || \"You have been banned\");\nlib/dispatcher.ts:1057 ws.disconnect();\nlib/dispatcher.ts:1058 clearAuth();",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "lifecycle",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "b9b86067",
|
||||
"test": "Client/tauri-client/tests/unit/main.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Widen the gate at main.ts:763 so the in-flight-session case is covered, e.g. `if (!isAuthenticated && (router.getCurrentPage() === \"main\" || connectedOverlay !== null))`, and add `connectedOverlay?.destroy(); connectedOverlay = null;` next to the existing `sessionCleanup?.()` teardown (main.ts:776-779) before the `router.navigate(\"connect\")` at 795. That is exactly the teardown handleInviteDeepLink already open-codes at main.ts:836-852, so one guard in the shared subscriber replaces the per-caller copies.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0158",
|
||||
"title": "Archived channel PATCH commits, then skips voice eviction and visibility fan-out when the post-commit re-read fails",
|
||||
"file": "Server/admin/handlers_channels.go",
|
||||
"line": 249,
|
||||
"severity": "medium",
|
||||
"why": "handlePatchChannel commits AdminUpdateChannel (which can set archived=1) and only afterwards re-reads the row with `database.GetChannel(r.Context(), id)`. That read is still bound to the admin's request context, so a client disconnect / deadline after the commit makes it fail, and the handler returns early — never calling hub.CleanupVoiceForChannel(id) nor hub.RefreshChannelVisibility(updated). The sibling handleDeleteChannel in the same file was explicitly hardened against exactly this (OC-0010, `delCtx := context.WithoutCancel(r.Context())`); the archive path was not.",
|
||||
"repro": "Admin PATCHes /admin/api/channels/{id} with archived=true on a voice channel that has live participants. AdminUpdateChannel commits. The admin's browser tab closes (or the network blips) before the handler's next statement, cancelling r.Context(). GetChannel returns context.Canceled -> handler writes 500 and returns. Result: the channel row is archived=1, but the live voice participants keep their voice_states row, their VoiceTopic subscription and their LiveKit session in a room no client can see, and every connected client still shows the channel in its sidebar until it reconnects. No sweep recovers this: CleanupVoiceForChannel is the only path that evicts them and it was skipped.",
|
||||
"evidence": "updated, err := database.GetChannel(r.Context(), id)\nif err != nil || updated == nil {\n\twriteErr(w, http.StatusInternalServerError, \"INTERNAL_ERROR\", \"failed to fetch updated channel\")\n\treturn\n}\nif hub != nil {\n\thub.BroadcastChannelUpdate(updated)\n\tif existing.Archived != updated.Archived {\n\t\tif !existing.Archived && updated.Archived {\n\t\t\thub.CleanupVoiceForChannel(id)\n\t\t}\n\t\thub.RefreshChannelVisibility(updated)\n\t}\n}",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "error-paths",
|
||||
"finder": "opus",
|
||||
"confidence": "medium",
|
||||
"fix": {
|
||||
"commit": "c9b72055",
|
||||
"test": "Server/admin/channels_archive_voice_test.go",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Mirror handleDeleteChannel: after AdminUpdateChannel returns, take tail := context.WithoutCancel(r.Context()) once and use it for the GetChannel re-read (and the existing WriteAudit), so a caller cancellation arriving after the commit cannot skip CleanupVoiceForChannel/RefreshChannelVisibility.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0159",
|
||||
"title": "Handshake/replay writes have no write deadline, so a client that stops reading pins a server goroutine + socket forever",
|
||||
"file": "Server/ws/serve.go",
|
||||
"line": 520,
|
||||
"severity": "medium",
|
||||
"why": "reconnectWriteReplay and handleFreshConnect write auth_ok, up to maxColdReplay (5000) replay frames, and the whole ready payload with `conn.Write(ctx, ...)` where ctx is the raw `r.Context()` captured at serve.go:69. websocket.Accept hijacks the connection and net/http's hijackLocked clears the deadlines srv.WriteTimeout (main.go:195) had set, so there is no OS-level deadline either, and the request context is never cancelled while ServeHTTP is still blocked. writePump — the only other writer — wraps every write in context.WithTimeout(ctx, writeTimeout) (serve_pumps.go:16) and authenticateConn wraps its handshake I/O in authDeadline (serve_auth.go:27), so this path is the one outlier. Nothing can unblock it: writePump has not started yet (startPumps runs only after the handshake returns, serve.go:69-98), and the only conn.Close calls are on this same goroutine's own error branches. sweepStaleClients fires at 90s and calls kickClient, which deletes the hub entry and closes the send channels but never touches conn (hub_sweep.go:57-69) — so the entry leaves h.clients (and stops counting against server.max_ws_connections) while the goroutine, TLS session and fd leak permanently.",
|
||||
"repro": "Authenticated client opens the WS, sends `auth` with last_seq ~5000 below the hub's current seq, then never reads from the socket (or is simply on a stalled/congested link). handleReconnect takes the buffer/db tier and enters the loop at serve.go:519-525. Once the peer's receive window plus the server's socket send buffer are full (a 5000-frame chat_message replay is multiple MB), conn.Write blocks with no deadline. At T+90s sweepStaleClients kicks the client out of h.clients — freeing the max_ws_connections slot — but the goroutine stays parked in conn.Write for the life of the process. Repeat to accumulate unbounded goroutines/fds. Same shape at serve.go:756 for a large `ready`.",
|
||||
"evidence": "serve.go:69 ctx := r.Context()\nserve.go:513 if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(...)); err != nil {\nserve.go:519 for _, evt := range events {\nserve.go:520 if err := conn.Write(ctx, websocket.MessageText, evt); err != nil {\nserve.go:756 if err := conn.Write(ctx, websocket.MessageText, ready); err != nil {\n-- contrast, serve_pumps.go:15-18 --\nfunc writePumpWrite(ctx context.Context, conn *websocket.Conn, c *Client, msg []byte) bool {\n\twCtx, cancel := context.WithTimeout(ctx, writeTimeout)\n\terr := conn.Write(wCtx, websocket.MessageText, msg)\n-- hub_sweep.go:56-69: kickClient deletes the map entry + closeSend + UnsubscribeAll, never conn.Close --",
|
||||
"status": "duplicate",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "flow-reconnect",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": null,
|
||||
"suggestedFix": "Add one helper in Server/ws and use it for all four handshake writes instead of raw conn.Write: func handshakeWrite(ctx context.Context, conn *websocket.Conn, msg []byte) error { wCtx, cancel := context.WithTimeout(ctx, writeTimeout); defer cancel(); return conn.Write(wCtx, websocket.MessageText, msg) } — apply at serve.go:513, 520, 748, 756 (and the error-frame write at 765). This mirrors writePumpWrite and needs no caller changes; the existing failure branches already run unregisterFailedHandshake + conn.Close on a write error.",
|
||||
"duplicateOf": "OC-0152",
|
||||
"rationale": "Same defect as OC-0152: unbounded conn.Write on the handshake/replay path in Server/ws/serve.go, found independently by the flow-reconnect lens. Both records propose the identical handshakeWrite(ctx,...) helper over the same call sites (513, 520, 748, 756, 764). Kept OC-0152 (higher severity, cites all five sites)."
|
||||
},
|
||||
{
|
||||
"id": "OC-0160",
|
||||
"title": "Client silently drops any WS frame over 1 MiB, and `ready` is the one unbounded frame — a large server's full-resync is discarded with no recovery",
|
||||
"file": "Client/tauri-client/src/lib/ws.ts",
|
||||
"line": 241,
|
||||
"severity": "medium",
|
||||
"why": "handleMessage drops any frame longer than maxMessageSizeBytes (DEFAULT_MAX_MESSAGE_SIZE = 1_048_576; neither main.ts:357, main.ts:186 nor cert-reconnect.ts:46 ever passes an override) with only a log.warn, before parsing. `ready` is the only server frame with no size bound: buildReady embeds `members` from database.ListMembers — every registered user, uncapped and unpaginated — plus roles, all visible channels and every open DM with its full last_message text. Unlike a sequenced frame, `ready` carries no seq so nothing re-requests it, and the drop happens after auth_ok already flipped the client to \"connected\" and reset lastSeq to 0 (ws.ts:323-330). The UI sits on the main page with empty/stale channelsStore, membersStore, dmStore and voiceStates, and every subsequent reconnect takes the same replay_source=\"none\" tier and drops the same oversized payload again.",
|
||||
"repro": "Server with ~3.5k registered users (each MemberSummary serializes to ~280-300 bytes: id, username, avatar path, status, role, ~124-char base64 identity_public_key, display_name, custom_status), so buildReady's members array alone exceeds 1_048_576 chars. Any fresh login or any full-resync reconnect (replay_source=\"none\": buffer window closed, cold-tier gap, or mustFullResync after a channel-visibility/DM-visibility change) delivers `ready`; ws.ts:241 returns before dispatch, the READY listener at dispatcher.ts:262 never runs, and the client renders a connected session with no channels and no member list. No error is surfaced and there is no retry path.",
|
||||
"evidence": "ws.ts:113 const DEFAULT_MAX_MESSAGE_SIZE = 1_048_576; // 1MB\nws.ts:239 const maxSize = config?.maxMessageSizeBytes ?? DEFAULT_MAX_MESSAGE_SIZE;\nws.ts:241 if (raw.length > maxSize) {\nws.ts:242 log.warn(\"Message exceeds size limit, dropping\", { size: raw.length });\nws.ts:243 return;\n-- Server/ws/serve_ready.go:326 --\nmembers, err := database.ListMembers(ctx) // no LIMIT, no pagination\nserve_ready.go:363-367 \"channels\": channelPayloads, \"members\": members, \"roles\": roles, \"dm_channels\": dmChannels\n-- main.ts:357 -- ws.connect({ host, token }); // no maxMessageSizeBytes",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "flow-reconnect",
|
||||
"finder": "opus",
|
||||
"confidence": "medium",
|
||||
"fix": {
|
||||
"commit": "f5aee939",
|
||||
"test": "Client/tauri-client/tests/unit/ws-messaging.test.ts, Client/tauri-client/tests/unit/ws-lifecycle.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "One guard in ws.ts handleMessage: move the size check after JSON.parse and exempt the handshake frame, e.g. parse first, then `if (raw.length > maxSize && parsed.type !== \"ready\" && parsed.type !== \"auth_ok\") { log.warn(...); return; }`. The frame is already fully materialized in `raw` by the time handleMessage runs, so the pre-parse check saves nothing but a parse and is what makes the drop unrecoverable. (A server-side complement — paginating members out of buildReady — is a larger change and not required to stop the data loss.)",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0161",
|
||||
"title": "A mistyped 2FA enrollment code signs the user out and permanently deletes their saved credential",
|
||||
"file": "Client/tauri-client/src/lib/api.ts",
|
||||
"line": 155,
|
||||
"severity": "medium",
|
||||
"why": "`doFetch` treats every HTTP 401 as \"the session expired\" and fires the global `onUnauthorized` sink, which calls `clearAuth()` (and, via main.ts's `isAuthenticated` subscriber, `deleteCredential(host)` + `skip-auto-login`). But `POST /users/me/totp/confirm` answers **401 UNAUTHORIZED / \"invalid two-factor code\"** for a wrong enrollment code — the one non-session 401 on any authenticated endpoint. Every sibling confirmation failure on the same handler family deliberately avoids 401 (wrong password -> 400 INVALID_INPUT via `requirePasswordConfirmation`; delete-account wrong password -> 400; change-password wrong password -> 403; disable-2FA policy refusal -> 403), so this is a drift, not a convention.",
|
||||
"repro": "1. Sign in to a server with \"remember me\" so a credential is stored (host H).\n2. Settings -> Account -> Enable two-factor authentication; enter the correct password, scan the QR.\n3. Type any wrong 6-digit code and submit.\n4. Server: handleConfirmTOTP -> VerifyTOTPCodeOnce fails -> 401 UNAUTHORIZED.\n5. Client: doFetch sees 401 -> onUnauthorized() -> setTransientError(\"Your session expired — sign in again.\") + clearAuth() -> isAuthenticated flips false while router page === \"main\" -> voice torn down, dispatcher/session cleanup, ws.disconnect(), deleteCredential(H), skip-auto-login set, navigate to the connect page.\nExpected: an inline \"invalid code, try again\" and the enrollment dialog stays open. Actual: full logout with a false \"session expired\" banner, the stored username/token/password for H erased, auto-login suppressed, and the pending 2FA enrollment (QR + backup codes) unrecoverable from the UI.",
|
||||
"evidence": "Client/tauri-client/src/lib/api.ts:154-158\n if (res.status === 401) {\n onUnauthorized?.();\n const err = await parseError(res);\n throw new ApiClientError(401, err.error, err.message);\n }\n\nServer/api/totp_handler.go:315-321 (handleConfirmTOTP)\n if !auth.VerifyTOTPCodeOnce(secret, strings.TrimSpace(req.Code), time.Now().UTC(), user.ID, usedTOTPCodes) {\n writeJSON(w, http.StatusUnauthorized, errorResponse{\n Error: \"UNAUTHORIZED\",\n Message: \"invalid two-factor code\",\n })\n return\n }\n\nClient/tauri-client/src/main.ts:120-129\n const api = createApiClient({ host: \"\" }, () => {\n if (authStore.getState().isAuthenticated) {\n setTransientError(\"Your session expired — sign in again.\");\n }\n clearAuth();\n });\n\nClient/tauri-client/src/main.ts:786-794 (authStore isAuthenticated subscriber)\n const host = api.getConfig().host;\n if (host && authStore.getState().logoutReason !== \"server_shutdown\") {\n void deleteCredential(host);\n sessionStorage.setItem(\"owncord:skip-auto-login\", \"1\");\n }\n router.navigate(\"connect\");\n\nClient/tauri-client/src/pages/MainPage.ts:486-495 (onConfirmTotp catches and toasts — after clearAuth already ran)\n\nNo test locks this: the client's own test mocks the wrong contract — tests/unit/api.test.ts:497-500 asserts confirmTotp with a **400 INVALID_CODE** response, a status the server never sends for this case. The server-side test (Server/api/totp_handler_test.go:299) only asserts the status code, not the client reaction.",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "hotspot-client-tauri-client-src-lib",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "01dca528",
|
||||
"test": "Client/tauri-client/tests/unit/api.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Give doFetch/request a per-call opt-out from the session-expiry sink (e.g. an options arg `{ sessionSink: false }` consulted at api.ts:155 before calling onUnauthorized) and set it on the single caller whose 401 is not a session verdict, confirmTotp (api.ts:395-396). Server-side alternative (larger blast radius): change totp_handler.go:316 to 400 INVALID_INPUT to match the sibling confirmation failures — but two tests assert 401 there (Server/api/auth_handler_test.go:1436, Server/api/totp_handler_test.go:299), so that is a deliberate contract change.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0162",
|
||||
"title": "Binding a push-to-talk key while already in a voice call leaves the microphone hot — PTT does not gate until the first press+release",
|
||||
"file": "Client/tauri-client/src/lib/ptt.ts",
|
||||
"line": 269,
|
||||
"severity": "medium",
|
||||
"why": "`updatePttKey` starts the poller via `initPtt()` but never applies the PTT gate to the already-published microphone. The Rust poller emits `ptt-state` only on a transition (`ptt_transition` returns `Some` only when `pressed != was_pressed`, and `was_pressed` starts at `false`), so an idle key produces no event at all. The user has a PTT key bound and believes transmission is gated, while the mic keeps streaming to every peer.",
|
||||
"repro": "1) Start the app with no PTT key bound (`pttVk` = 0), so `initPtt()` at main.ts:107 returns immediately and `listening` stays false. 2) Join a voice channel. `LiveKitSession.restoreLocalVoiceState(\"join\")` computes `pttArmed = isPttPollingLive() && loadPref(\"pttVk\",0) !== 0` = false, publishes the mic, `pttGated` stays false. 3) While still in the call, open Settings -> Keybinds and bind a key. `updatePttKey(vk)` -> `ptt_set_key`, then `!listening && vk !== 0` -> `initPtt()`, which calls `ptt_start`, sets `setPttPollingLive(true)`, registers the `ptt-state` listener and sets `listening = true` — but never calls `setPttGated(true)` and never calls `setMuted(true)`. 4) The Rust thread starts with `was_pressed = false` and the key idle, so `ptt_transition(vk, false, false)` returns `None` on every tick and no `ptt-state` event is ever emitted. Result: mic stays published and unmuted, `voiceStore.pttGated` stays false (so `isMicPolicyGated()` in deviceManager.ts:32 also reports ungated), and the user keeps transmitting until they physically press and release the key once. Note the teardown direction IS handled — `stopPtt()` calls `ungateMic(mutedByPtt)` precisely so a PTT-applied mute is never stranded — which makes the missing setup-side gate an asymmetry, not a deliberate omission.",
|
||||
"evidence": "export async function updatePttKey(vk: number): Promise<void> {\n savePref(\"pttVk\", vk);\n ...\n await invoke(\"ptt_set_key\", { vkCode: vk });\n if (!listening && vk !== 0) {\n await initPtt(); // starts the poller; never setPttGated(true) / setMuted(true)\n }\n if (vk === 0) {\n await stopPtt(); // the reverse direction DOES rearm, via ungateMic()\n }\n\n// Rust side (src-tauri/src/ptt.rs:311):\nfn ptt_transition(vk: i32, key_down: bool, was_pressed: bool) -> Option<bool> {\n let pressed = vk != 0 && key_down;\n (pressed != was_pressed).then_some(pressed) // idle key after start => None, forever\n}",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-1",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "e391caeb",
|
||||
"test": "Client/tauri-client/tests/unit/ptt.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "In updatePttKey, after the `await initPtt()` succeeds, arm the gate if a call is already up — mirroring the join-time computation: if `voiceStore.getState().currentChannelId !== null && isPttPollingLive() && voiceStore.getState().pttGated !== true`, call `setPttGated(true)`, and if the user is not already self-muted/deafened, `setMuted(true)` and set `pttOwnsMute = true` so the next press may lift it. Putting it in updatePttKey (not initPtt) keeps the startup path, which has no call in progress, unchanged.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0163",
|
||||
"title": "A bare (unbracketed) IPv6 host is accepted everywhere except the WebSocket URL, which is built by raw interpolation — login succeeds, the socket never connects",
|
||||
"file": "Client/tauri-client/src/lib/ws.ts",
|
||||
"line": 540,
|
||||
"severity": "medium",
|
||||
"why": "`connect()` builds `wss://${cfg.host}/api/v1/ws` with no bracketing, so a bare IPv6 host (`2001:db8::1`) yields the unparseable URL `wss://2001:db8::1/api/v1/ws`. Every sibling path deliberately supports bare IPv6: `isValidHost` (api.ts:91) explicitly allows it, `http_proxy.rs::split_host_port`/`resolve_remote_target` brackets it for the dial target, and `livekitSession.ensureLiveKitProxy` wraps it in brackets before invoking the LiveKit proxy. Only ws.ts does not.",
|
||||
"repro": "On the Connect page enter the server address as a bare IPv6 literal, e.g. `2001:db8::1` or `::1` (accepted by `api.setConfig` — locked in by tests/unit/api.test.ts:366 \"accepts a bare (unbracketed) IPv6 literal\"). The health check and login both succeed because REST goes through the Rust http proxy, which brackets the dial target. `wirePostAuth` then calls `ws.connect({host: \"2001:db8::1\", token})`, which invokes `ws_connect` with `wss://2001:db8::1/api/v1/ws`; tokio-tungstenite's URL parse rejects that authority (host `2001`, port `db8::1`), `ws_connect` returns `ws connect failed: …`, and `scheduleReconnect()` retries the same malformed URL forever. The user is logged in but never receives `ready` — no channels, no messages. Using `[2001:db8::1]` instead works everywhere.",
|
||||
"evidence": "ws.ts:540 const wsUrl = `wss://${cfg.host}/api/v1/ws`;\n\napi.ts:87-91 // Bare (unbracketed) IPv6 literal, e.g. \"2001:db8::1\" or \"::1\".\n if ((host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host)) return true;\n\nlivekitSession.ts:759-761 } else if ((this.serverHost.match(/:/g) ?? []).length > 1) {\n // Bare IPv6 (multiple colons) — wrap in brackets and add default port\n hostWithPort = `[${this.serverHost}]:443`;\n\nhttp_proxy.rs:311-315 let dial_target = if hostname.contains(':') { format!(\"[{hostname}]:{port}\") } else { ... };",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "hotspot-client-tauri-client-src-lib",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "f5aee939",
|
||||
"test": "Client/tauri-client/tests/unit/ws-messaging.test.ts, Client/tauri-client/tests/unit/ws-lifecycle.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Canonicalize the host to bracketed form once, at the single place it enters the app, rather than in each consumer: in api.ts's isValidHost/setConfig path (or a small exported `canonicalizeHost` used by main.ts's onLogin/onRegister and the profile store), rewrite a bare multi-colon IPv6 literal to `[...]` before it is stored — every other consumer (http_proxy.rs, livekit_proxy.rs, livekitSession.ensureLiveKitProxy) already handles the bracketed form. If a local fix is preferred, bracket at ws.ts:540: `const hostForUrl = !cfg.host.startsWith(\"[\") && (cfg.host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(cfg.host) ? `[${cfg.host}]` : cfg.host;` — but then also strip brackets in tofu::cert_store_key (src-tauri/src/tofu.rs:292), or the ws pin key `[2001:db8::1]` will not match the http proxy's pin key `2001:db8::1` and the user gets a second first-use cert prompt.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0164",
|
||||
"title": "The server's \"update_aborted\" restart-cancel broadcast puts the client into a permanent \"Reconnecting...\" banner — the exact failure it was added to prevent",
|
||||
"file": "Client/tauri-client/src/pages/MainPage.ts",
|
||||
"line": 363,
|
||||
"severity": "medium",
|
||||
"why": "When a staged update fails to swap, the server sends `server_restart{reason:\"update_aborted\", delay_seconds:0}` purely to *cancel* the countdown it already announced. The client has no cancel path: MainPage treats every non-\"shutdown\" reason as a new restart announcement and calls `banner.showRestart(0)`, whose interval immediately falls through `remaining <= 0` into `showReconnecting()`. Because the WebSocket never actually drops, `uiStore.connectionStatus` never changes, the change-only subscription at MainPage.ts:334 never fires, and nothing ever calls `banner.hide()`.",
|
||||
"repro": "Admin panel -> apply update. Server broadcasts `server_restart{reason:\"update\",delay_seconds:5}` (Server/admin/update_handlers.go:149); client shows the countdown. The staged-binary re-verification or rename then fails, so `applyStagedUpdate`'s deferred guard broadcasts `server_restart{reason:\"update_aborted\",delay_seconds:0}` (Server/admin/update_handlers.go:181) and the process does NOT restart. Client: MainPage.ts:363 calls `showRestart(0)` -> ServerBanner.ts:33 paints \"Server restarting in 0 seconds...\", then at t=1000ms ServerBanner.ts:36-40 computes remaining=-1, clears the interval and calls `showReconnecting()`. The socket stays connected forever, so the banner reads \"Reconnecting...\" over a perfectly healthy session until the app is restarted. dispatcher.ts:998 additionally sets the transient error string \"Server is restarting: update_aborted\".",
|
||||
"evidence": "MainPage.ts:359-364:\n if (banner !== null && payload.reason !== \"shutdown\") {\n banner.showRestart(payload.delay_seconds);\n }\n\nServerBanner.ts:35-43:\n intervalId = setInterval(() => {\n remaining -= 1;\n if (remaining <= 0) { clearCountdown(); showReconnecting(); return; }\n setText(root, `Server restarting in ${remaining} seconds...`);\n }, 1000);\n\nServer/admin/update_handlers.go:163-181 (the intent this violates):\n // The caller has already broadcast \"restarting in 5s\" ... every failure path\n // must correct that promise -- otherwise the client's restart banner counts\n // down to a permanent \"Reconnecting...\" over a connection that never\n // actually dropped (OC-0226).\n defer func() { if !committed && hub != nil { hub.BroadcastServerRestart(\"update_aborted\", 0) } }()",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-1",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "c7d8e13c",
|
||||
"test": "Client/tauri-client/tests/unit/main-page.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "In the single shared handler at MainPage.ts:361-364, treat a zero/negative delay as a cancel rather than a countdown: `if (banner !== null && payload.reason !== \"shutdown\") { if (payload.delay_seconds <= 0) applyConnectionStatus(banner, uiStore.getState().connectionStatus); else banner.showRestart(payload.delay_seconds); }`. That re-syncs the banner to the real connection status (hidden while connected) and covers any future cancel broadcast without a per-reason list.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0165",
|
||||
"title": "scanPluginDirectory aborts the entire scan on the first bad plugin directory, so one malformed plugin disables every other plugin",
|
||||
"file": "Server/plugin/loader.go",
|
||||
"line": 87,
|
||||
"severity": "medium",
|
||||
"why": "Every per-plugin failure (malformed plugin.json, malformed plugin.toml, missing .wasm entrypoint, a stray symlink anywhere in the tree) returns from `scanPluginDirectory` instead of skipping that one directory. `Registry.LoadAll` (registry.go:171) propagates that error and returns *before* the `installFromDisk` loop and before `activateAll`, and `main.go:406` only logs a warning. The registry therefore starts completely empty. This contradicts the per-plugin `continue` policy LoadAll already applies to `installFromDisk` failures (registry.go:176) and the documented contract of `tryLoadPluginTOML` (\"the caller will skip the plugin and log the error\" — manifest_toml.go:19), which the caller does not honour.",
|
||||
"repro": "With plugins enabled and two plugins on disk (`plugins/hello/` working, `plugins/broken/plugin.json` containing a trailing comma or with its .wasm deleted), restart the server. scanPluginDirectory returns `plugin \"broken\": ...` at loader.go:72 or :87; LoadAll returns that error; main.go logs `plugin loader: failed to scan directory` and continues. `hello` is never installed into r.byName and never activated, so every `/hello` slash command dispatch now misses — while the `plugins` DB row still says enabled=1, so the admin panel lists it as enabled. Removing the broken directory and restarting restores it, proving the good plugin was collateral damage.",
|
||||
"evidence": "loader.go:72 return nil, fmt.Errorf(\"plugin %q: %w\", e.Name(), parseErr)\nloader.go:87 return nil, fmt.Errorf(\"plugin %q: missing entrypoint %s: %w\", e.Name(), manifest.Entrypoint, statErr)\nregistry.go:171-173 manifests, err := scanPluginDirectory(r.cfg.Directory); if err != nil { return fmt.Errorf(...) }\nregistry.go:176 if err := r.installFromDisk(ctx, found); err != nil { slog.Warn(...); continue } // per-plugin policy, never reached\nregistry.go:180 return r.activateAll(ctx) // never reached",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-3",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "4bdb24e3",
|
||||
"test": "Server/plugin/loader_test.go",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Make the scan per-plugin fault-tolerant in one place: in scanPluginDirectory, replace each per-entry `return nil, ...` with recording the error (errors.Join into a scanErr) and `continue`, then `return found, scanErr`; in registry.go LoadAll, log the returned error via slog.Warn and proceed with `manifests` instead of returning early. That keeps TestScanPluginDirectoryRejectsSymlinkEntrypoint green (err is still non-nil) while good plugins still install and activate.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0166",
|
||||
"title": "A user's own presence is split across two per-client queues with different drain order: the invisible self-frame goes high-priority, every other frame goes normal",
|
||||
"file": "Server/ws/emit.go",
|
||||
"line": 77,
|
||||
"severity": "low",
|
||||
"why": "`PresenceSelfEvent` satisfies `UserTargetedEvent`, so EmitEvents routes it through `h.SendToUserHigh` (c.sendHigh). Every other frame carrying the same user's own status uses the normal queue — `PresenceEvent`→`BroadcastToAll` (emit.go:105) for a visible status, and `BroadcastPresence`'s private half `h.SendToUser(userID, ...)` (hub_broadcast.go:737) for the connect/disconnect coalescer flush. writePump drains sendHigh strictly before send (serve_pumps.go priority 1 and 2), so a newer invisible self-frame can be written ahead of an older visible-status frame still queued in `send`, and the owner's client settles on the stale one. This is exactly the split-FIFO hazard the surrounding comments say was fixed for the \"others\" half in OC-0003/OC-0214; the self half still has it.",
|
||||
"repro": "User U is connected and U's normal send queue has a backlog (busy channel, or a stalled/slow socket — the same precondition OC-0003 assumed). (1) U sends presence_update{status:\"online\"|\"dnd\"} → handlePresenceV2 → presenceEvents → single PresenceEvent → BroadcastToAll → deliverBroadcast → frame appended to U's `c.send` behind the backlog. (2) >=3s later U sends presence_update{status:\"invisible\"} → presenceEvents returns PresenceOthersEvent (broadcast to everyone EXCEPT U) + PresenceSelfEvent → EmitEvents hits the UserTargetedEvent branch → SendToUserHigh → frame lands on U's empty `c.sendHigh`. (3) writePump's priority-1 select drains sendHigh first, so U's socket receives presence{U, invisible} and only afterwards presence{U, dnd}. U's own status picker ends up showing \"Do Not Disturb\" while users.status is 'invisible' and every other client sees U as offline. The self-frame is unsequenced (SendToUserHigh does not wrapWithSeq), so reconnect replay never repairs it; only a fresh auth_ok (which carries user.Status) does.",
|
||||
"evidence": "emit.go:62-77 `case UserTargetedEvent:` … `h.SendToUserHigh(e.TargetUserID(), e.Payload())`\nevent.go:270-277 `type PresenceSelfEvent struct{ targetUserID int64; payload []byte }` with `TargetUserID()`+`Payload()` (satisfies UserTargetedEvent)\nevent.go:286-311 `presenceEvents` returns `[]Event{PresenceOthersEvent{...}, PresenceSelfEvent{...}}` only when `db.BroadcastStatus(status) != status`, i.e. status == invisible\nhub_broadcast.go:736-737 `h.BroadcastToAllExcept(userID, ...)` / `h.SendToUser(userID, buildPresenceMsg(userID, status, customStatus))` ← same logical frame, NORMAL queue\nserve_pumps.go:70-101 writePump: \"Priority 1: drain all pending high-priority messages first\"",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "ws-hub",
|
||||
"finder": "opus",
|
||||
"confidence": "medium",
|
||||
"fix": {
|
||||
"commit": "21a1a73d",
|
||||
"test": "Server/ws/emit_presence_self_priority_test.go",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Keep every source of one user's presence on the same per-client FIFO: in EmitEvents (Server/ws/emit.go), add `case PresenceSelfEvent: h.SendToUser(e.targetUserID, e.payload)` immediately BEFORE `case UserTargetedEvent` (Go type switches take the first matching case), mirroring how PresenceOthersEvent is special-cased out of the low-priority default. One guard in the shared router, no caller changes.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0167",
|
||||
"title": "The queued-announce drain and the per-announce offer send bypass the rate pacing entirely, so a key holder joining a large ongoing call fires one unpaced offer per existing participant",
|
||||
"file": "Client/tauri-client/src/lib/livekitE2EE.ts",
|
||||
"line": 245,
|
||||
"severity": "low",
|
||||
"why": "`setupKeyExchange`'s drain loop calls `handleAnnounce` once per queued announce, and `handleAnnounceInner` sends a `voice_e2ee_offer` per call (line 807) with no reference to `distributeRoomKey`'s `sentInWindow` budget and no pacing of its own. For a key holder joining an ongoing call, every existing participant's relayed announce is queued (they arrive while `_ecdhKeyPair` is still null), so the drain emits N offers in one uninterrupted burst — the exact shape OC-0005's pacing was added to prevent, on a sibling path the fix never covered.",
|
||||
"repro": "A voice channel has 70 participants. A user whose user_id is lower than all of them joins, so the server sets `is_key_holder = true` in voice_token (Server/ws/voice_join.go:438). voiceJoinComplete then pushes 70 `voice_e2ee_announce` frames to the joiner; all 70 land while connectAndSetup is still awaiting createRoom/resolveLiveKitUrl/generateECDHKeyPair/buildAnnouncePayload, so handleAnnounceInner queues every one of them into `_pendingAnnounces` (line 701-704). setupKeyExchange then drains all 70 in a tight loop, each producing one `voice_e2ee_offer`. The server's (sender, channel) sliding window caps at 64/s, so the last ~6 offers are rejected with ErrCodeRateLimited and dropped with no client-side retry — those participants never receive the new holder's room key and cannot hear or be heard by anyone until the joiner's 5-minute periodic rotation.",
|
||||
"evidence": "livekitE2EE.ts:244-249\n const queued = this._pendingAnnounces.splice(0);\n for (const { userId: qId, publicKeyBase64: qKey, signatureBase64: qSig } of queued) {\n await this.handleAnnounce(qId, qKey, qSig);\n log.info(\"E2EE: drained queued announce\", { userId: qId });\n }\n\nlivekitE2EE.ts:786-811 (handleAnnounceInner)\n if (this._isKeyHolder && currentRoomKey && keypair) {\n ...\n this.deps.getWs()?.send({\n type: \"voice_e2ee_offer\",\n payload: { target_user_id: userId, encrypted_key: encryptedKey, iv },\n });\n\nServer/ws/voice_join.go:490-495 — voiceJoinComplete replays every existing participant's announce to the joiner:\n if pubKey, sig := h.getClientE2EEPubKey(vs.UserID); pubKey != \"\" {\n c.sendMsg(buildVoiceE2EEAnnounce(vs.UserID, pubKey, sig))\n }",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "voice-e2ee",
|
||||
"finder": "opus",
|
||||
"confidence": "medium",
|
||||
"fix": {
|
||||
"commit": "5db10850",
|
||||
"test": "Client/tauri-client/tests/unit/livekit-e2ee.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Same single fix as candidate 1 — route livekitE2EE.ts:806-810's `getWs()?.send({type: \"voice_e2ee_offer\", ...})` through the shared instance-level `sendOfferPaced(...)` helper rather than calling `getWs()?.send` directly, so announce-driven offers draw from the same sliding-window budget as rotation offers. No per-caller pacing and no change to the drain loop at 244-249 is needed.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0168",
|
||||
"title": "PluginKVScan implements a prefix scan with LIKE, so '_'/'%' in the prefix are wildcards and matching is ASCII-case-insensitive — disagreeing with the exact-match get/set/delete on the same table",
|
||||
"file": "Server/db/plugin_queries.go",
|
||||
"line": 116,
|
||||
"severity": "low",
|
||||
"why": "The contract is a prefix scan (parameter is named `prefix`; Registry.StorageScan documents \"all keys with the given prefix\"), but the SQL uses LIKE. SQLite's LIKE treats `_` as a single-character wildcard and `%` as a multi-character wildcard, and is ASCII-case-insensitive by default. PluginKVGet/Set/Delete use `key = ?` (BINARY, case-sensitive), so the two halves of the same KV API disagree about which keys are the same key.",
|
||||
"repro": "Wildcard case: a plugin holding the `storage` capability calls StoragePut(\"cfg_a\", v1) and StoragePut(\"cfgXa\", v2) — two distinct rows. StorageScan(\"cfg_\", 100) issues `key LIKE 'cfg_%'`; `_` matches any single character, so the map comes back with BOTH \"cfg_a\" and \"cfgXa\" even though only the first has the requested prefix. Underscore-separated key namespaces are the common case, so this over-matches routinely.\n\nCase case: StoragePut(\"Key1\", v1) and StoragePut(\"key1\", v2) create two distinct rows (BINARY PK, and PluginKVGet's `key = ?` reads them back as two distinct keys). StorageScan(\"Key\", 100) issues `key LIKE 'Key%'`, which is ASCII-case-insensitive, and returns both rows — so the scan reports keys that the plugin's own get/delete calls treat as different keys.",
|
||||
"evidence": "Server/db/plugin_queries.go:114-118 —\nfunc (d *DB) PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) {\n\trows, err := d.reader.QueryContext(ctx,\n\t\t`SELECT key, value FROM plugin_kv WHERE plugin_id = ? AND key LIKE ? ORDER BY key LIMIT ?`,\n\t\tpluginID, prefix+\"%\", limit,\n\t)\n\nSiblings on the same table use exact match:\nServer/db/plugin_queries.go:87 `SELECT value FROM plugin_kv WHERE plugin_id = ? AND key = ?`\nServer/db/plugin_queries.go:99-100 `INSERT INTO plugin_kv (plugin_id, key, value) VALUES (?, ?, ?) ON CONFLICT(plugin_id, key) DO UPDATE SET value = excluded.value`\nServer/db/plugin_queries.go:108 `DELETE FROM plugin_kv WHERE plugin_id = ? AND key = ?`\n\nNo `PRAGMA case_sensitive_like` is set — Server/db/db.go:57-64 `filePragmas` lists busy_timeout, journal_mode, foreign_keys, synchronous, temp_store, mmap_size, cache_size only, and openMemory (db.go:150-160) Execs the same set.\n\nplugin_kv's key collation is the default BINARY: Server/migrations/015_plugins.sql declares `key TEXT NOT NULL` with `PRIMARY KEY (plugin_id, key)`.\n\nCaller contract: Server/plugin/host_storage.go:59-67 — \"StorageScan returns all keys with the given prefix, capped at maxPluginScanLimit.\"",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "db-storage",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "a4d5be62",
|
||||
"test": "Server/db/plugin_queries_test.go",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Make the scan a BINARY prefix comparison instead of a pattern match, in the one shared query. In Server/db/plugin_queries.go:115-118 replace `key LIKE ?` / `prefix+\"%\"` with `key >= ? AND substr(key, 1, length(?)) = ?`, binding prefix three times (pluginID, prefix, prefix, prefix, limit). The `key >= ?` term keeps the (plugin_id, key) primary-key index usable for the seek, and substr() compares under BINARY, so wildcards and ASCII case-folding both disappear. Do not fix by adding `ESCAPE '\\\\'` — that handles `_`/`%` but leaves LIKE's case-insensitivity, which is the half that disagrees with PluginKVGet/Delete.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0169",
|
||||
"title": "A failed keyring write deletes the existing secret before the fallback write that may also fail, permanently destroying a good stored credential/identity key",
|
||||
"file": "Client/tauri-client/src-tauri/src/secret_store.rs",
|
||||
"line": 164,
|
||||
"severity": "low",
|
||||
"why": "In `set_with`'s failed-write arm the destructive `keyring_delete(account)` runs unconditionally at line 164, but the write that is supposed to take over — `fallback_set(account, secret)?` — only runs afterwards at line 173 and can itself fail. When both fail, the previously stored (and perfectly readable) keyring entry has already been erased and nothing replaced it, so the next `get()` returns `Ok(None)` — the exact \"indistinguishable from first login\" signal this module's own `get_with` tests (lines 512-521) declare must never be produced.",
|
||||
"repro": "Linux/macOS machine whose `credential_fallback.key` is present but truncated (e.g. a prior ENOSPC before the `finish_new_key_file` cleanup landed, or a partially-restored backup). `fallback_crypto::load_or_create_key` then returns Err(\"credential fallback key file has N bytes, expected 32 — refusing to use it\") on every call, so `protect_secret` -> `set_fallback` always fails. 1) The user's voice-E2EE identity private key is already stored fine in the Secret Service under account `identity:{userId}@{host}`. 2) A reconnect triggers `save_identity_key`; the Secret Service write fails transiently (keyring locked after screen-lock, or a D-Bus timeout) -> `keyring_set` Err. 3) Line 164 deletes the good keyring entry. 4) Line 173 `fallback_set` fails, `set_with` returns Err, `save_identity_key` returns Err. 5) On the next launch `load_identity_key` -> `get_with`: `keyring_get` Ok(None) (deleted), `get_fallback` None -> `Ok(None)`. 6) `identity.ts`'s `getOrCreateIdentityKeyPair` reads that as first login and mints a brand-new keypair; every peer's pinned identity key now mismatches and `livekitE2EE.verifyPeerAnnounce` rejects the announce as a possible MITM until each peer manually re-pins. Fix: run `fallback_set` first and delete the keyring entry only once the fallback copy is committed. The existing test `set_with_deletes_any_stale_keyring_entry_when_the_write_fails` (line 537) still passes under that order — it only asserts the delete happened and the result is FALLBACK_BACKEND.",
|
||||
"evidence": " Err(e) => {\n log::error!(\"{SERVICE}: credential store write failed for '{account}': {e}\");\n // An older secret may already sit in the keyring from a prior\n // successful write. get() reads the keyring first, so leaving\n // that stale entry in place would shadow the fresh secret parked\n // in the fallback below — mirrors the read-back-mismatch arm\n // above, which purges for the same reason.\n if let Err(de) = keyring_delete(account) { // <-- line 164, destructive, runs first\n log::warn!(...);\n }\n }\n }\n\n fallback_set(account, secret)?; // <-- line 173, fallible, runs second",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "tauri-rust",
|
||||
"finder": "opus",
|
||||
"confidence": "medium",
|
||||
"fix": {
|
||||
"commit": "d1068247",
|
||||
"test": "Client/tauri-client/src-tauri/src/secret_store.rs (mod tests: set_with_keeps_the_stale_keyring_entry_when_the_write_and_fallback_both_fail)",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "In set_with's `Err(e)` arm (secret_store.rs:161-170), do not delete yet — record the intent (e.g. `let purge_stale_keyring = true;`) and run the delete only after `fallback_set(account, secret)?` at line 173 has committed the replacement copy: `fallback_set(account, secret)?; if purge_stale_keyring { if let Err(de) = keyring_delete(account) { log::warn!(...) } }`. That keeps the anti-shadowing invariant the delete exists for, keeps the existing set_with_deletes_any_stale_keyring_entry_when_the_write_fails test green (it only asserts the delete ran and the result is FALLBACK_BACKEND), and leaves the old keyring copy intact when the fallback write fails. Leave the read-back-mismatch arm's delete where it is — the entry it purges is a value the store never received from us.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0170",
|
||||
"title": "roles_update fan-out is tied to the request context, so a role create/update/delete commits with no client ever told",
|
||||
"file": "Server/admin/handlers_roles.go",
|
||||
"line": 262,
|
||||
"severity": "low",
|
||||
"why": "broadcastRoles re-reads the role list with `database.ListRoles(r.Context())` after the mutation has already committed. A request context cancelled between the commit and this read makes ListRoles return context.Canceled, and the roles_update broadcast is skipped for every connected client. This is the exact bug already fixed in Server/api/emoji_handler.go:273 (broadcastEmojiSet now uses context.WithoutCancel with a comment naming this hazard) and in Server/api/dm_handler.go:257 (broadcastDMOpen); handlers_roles.go was not converted.",
|
||||
"repro": "Admin deletes a role via DELETE /admin/api/roles/{id}. roles.DeleteRole commits and the moved members get their per-user BroadcastMemberUpdate. The admin then closes the tab, cancelling r.Context(). broadcastRoles' ListRoles fails with context.Canceled, logs a Warn and returns without broadcasting. Every connected client keeps the deleted role in its role list — so role colors, member-list grouping and permission-gated affordances stay keyed on a role that no longer exists — until each client reconnects. The fan-out is also only half applied: the member_update frames went out, the roles_update that gives them meaning did not.",
|
||||
"evidence": "func broadcastRoles(r *http.Request, database *db.DB, hub HubBroadcaster) {\n\tif hub == nil || database == nil {\n\t\treturn\n\t}\n\tlist, err := database.ListRoles(r.Context())\n\tif err != nil {\n\t\tslog.Warn(\"admin: roles_update broadcast skipped, role list unreadable\", \"err\", err)\n\t\treturn\n\t}\n\thub.BroadcastRolesUpdate(list)\n}",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "error-paths",
|
||||
"finder": "opus",
|
||||
"confidence": "medium",
|
||||
"fix": {
|
||||
"commit": "6cd7c01a",
|
||||
"test": "Server/admin/handlers_roles_test.go",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "In broadcastRoles, read with context.WithoutCancel(r.Context()) instead of r.Context() — one change in the shared helper covers all three call sites.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0171",
|
||||
"title": "Optimistic send reconciles in place, so a message that arrives while the send is in flight is rendered permanently out of id/time order",
|
||||
"file": "Client/tauri-client/src/stores/messages.store.ts",
|
||||
"line": 257,
|
||||
"severity": "low",
|
||||
"why": "The store keeps messages in pure array order and never re-sorts: `addOptimisticMessage` appends a pending row at the tail before its real id exists, `addMessage` appends every later broadcast after it, and both `confirmSend` (line 759) and `addMessage`'s two reconciliation branches (lines 226 and 245) replace the optimistic row *at its existing index* (`existing.map((m, i) => (i === idIdx ? message : m))`). If any other user's message commits and is broadcast between the optimistic insert and the local echo, the local row ends up holding a higher server id and a later server timestamp than the row sitting after it, and nothing ever repairs the inversion for the life of the loaded window.",
|
||||
"repro": "Channel 1 loaded with [id 100]. (1) User A types and sends -> addOptimisticMessage appends {id:0, status:'pending'}; array = [100, pending]. (2) Before A's send commits server-side, user B's message commits as id 101 and is broadcast; A's client dispatches chat_message(101) -> addMessage: no id match (pending row still id 0), isUnreconciledEcho is false (different author), so it appends -> array = [100, pending, 101]. (3) A's chat_send_ok arrives with message_id 102 -> confirmSend rewrites the pending row in place -> array = [100, 102, 101]. (4) A's own chat_message(102) echo arrives -> idIdx finds 102 at index 1 and replaces it in place. Final array = [100, 102, 101]. MessageList's buildVirtualItems walks this array verbatim (no sort anywhere in messages.store.ts or MessageList.ts), so A sees their own newer message rendered above B's older one, with the timestamp header going backwards; the day-divider/grouping logic (isSameDay(prevMsg.timestamp, msg.timestamp), shouldGroup) also runs on the non-monotonic sequence. The state only self-corrects on a full window refetch (channel switch + invalidateChannelMessageWindow, or reconnect).",
|
||||
"evidence": "messages.store.ts:226-233 const idIdx = existing.findIndex((m) => m.id !== 0 && m.id === message.id); if (idIdx !== -1) { const replaced = existing.map((m, i) => (i === idIdx ? message : m)); ... }\nmessages.store.ts:257 let updatedMsgs = [...existing, message];\nmessages.store.ts:759-763 const updatedList = existing.map((m) => m.correlationId === correlationId ? { ...m, id: messageId, timestamp, status: \"sent\" as const, errorCode: null } : m,);",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "ordering-boundary",
|
||||
"finder": "opus",
|
||||
"confidence": "medium",
|
||||
"fix": {
|
||||
"commit": "5980a38b",
|
||||
"test": "Client/tauri-client/tests/unit/messages.store.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "One guard in addMessage's append branch (messages.store.ts:257): keep unreconciled rows at the tail instead of appending blindly — walk back over trailing rows whose status !== \"sent\" and splice the server message in before them, e.g. `let at = existing.length; while (at > 0 && existing[at-1]!.status !== \"sent\") at--; let updatedMsgs = [...existing.slice(0,at), message, ...existing.slice(at)];` leaving the MAX_MESSAGES_PER_CHANNEL trim below unchanged. With the pending row kept last, confirmSend's in-place stamp can no longer produce an id/timestamp inversion, and MessageList's tryAppendMessages simply falls back to a full rebuild (it already returns false for any non-suffix change).",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0172",
|
||||
"title": "A GetChannelVoiceStates error aborts the voice_join tail after the join is already committed and broadcast, so the joiner never receives existing peers' E2EE announces and is guaranteed to fail the key exchange",
|
||||
"file": "Server/ws/voice_join.go",
|
||||
"line": 480,
|
||||
"severity": "low",
|
||||
"why": "voiceJoinComplete has already subscribed the client to the voice topic, re-elected the key holder, and broadcast the joiner's voice_state to everyone when it reads the existing participants. A read error makes it `return` with no error frame, no rollback, and — critically — without relaying any existing participant's ECDH public key. That relay is the ONLY place the server ever ships a peer's stored `voice_e2ee_announce` to a joiner (mid-call peers never counter-announce; handleAnnounceInner replies with an offer), so the joiner's `_peerPublicKeys` stays empty and the key holder's offer is dropped by handleOfferInner's unknown-peer guard.",
|
||||
"repro": "Channel 5 has key holder A (uid 10) live in voice. B (uid 20) sends voice_join for channel 5. B passes every gate, the voice_states row commits, c.setVoiceState runs, voice_token (is_key_holder=false) is delivered, updateKeyHolder keeps A, and B's voice_state is broadcast to the whole READ audience. Now the GetChannelVoiceStates read at voice_join.go:479 fails (SQLite busy/IO error). handleVoiceJoin returns silently: B receives no voice_state for A, no voice_e2ee_announce for A, and no voice_config. B's client runs setupKeyExchange as a non-holder, announces, and A duly answers with voice_e2ee_offer — which B drops at livekitE2EE.ts:860 as \"received offer from unknown peer\" because A's ECDH key was never relayed. B waits 10s, re-announces, waits 5s more, then setupKeyExchange returns false and connectAndSetup fires onErrorCallback(\"e2ee_timeout\") and leaveVoice(true) (livekitSession.ts:1103-1138). Every other client saw B join and then leave 15s later; B got no error frame explaining why, and each retry fails identically while the read error persists. (Compare the sibling fix at Server/ws/serve_ready.go:242, where buildReady's swallowed DB errors were made fatal for exactly this reason.)",
|
||||
"evidence": "Server/ws/voice_join.go:479-483\n\texisting, err := h.db.GetChannelVoiceStates(ctx, channelID)\n\tif err != nil {\n\t\tslog.Error(\"ws handleVoiceJoin GetChannelVoiceStates\", \"err\", err)\n\t\treturn\n\t}\n\n…the announce relay it skips (voice_join.go:493):\n\t\tif pubKey, sig := h.getClientE2EEPubKey(vs.UserID); pubKey != \"\" {\n\t\t\tc.sendMsg(buildVoiceE2EEAnnounce(vs.UserID, pubKey, sig))\n\t\t}\n\n…the receiving guard that then drops the holder's offer (Client/tauri-client/src/lib/livekitE2EE.ts:859):\n const peerKey = this._peerPublicKeys.get(fromUserId);\n if (!peerKey) {\n log.warn(\"E2EE: received offer from unknown peer\", { fromUserId });\n return;\n }",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "flow-voice",
|
||||
"finder": "opus",
|
||||
"confidence": "medium",
|
||||
"fix": {
|
||||
"commit": "dddd0560",
|
||||
"test": "Server/ws/oc_0172_voice_join_getchannelvoicestates_error_test.go",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Treat the read as fatal, matching every other post-commit failure in this handler, instead of returning silently. In voiceJoinComplete (Server/ws/voice_join.go:479-483):\n\n\texisting, err := h.db.GetChannelVoiceStates(ctx, channelID)\n\tif err != nil {\n\t\tslog.Error(\"ws handleVoiceJoin GetChannelVoiceStates\", \"err\", err)\n\t\t// The joiner's own voice_state was already broadcast above, so this\n\t\t// rollback must broadcast the compensating voice_leave.\n\t\th.rollbackVoiceJoin(ctx, c, channelID, state.JoinedAt, true)\n\t\tc.sendMsg(buildErrorMsg(ErrCodeInternal, \"failed to join voice channel\"))\n\t\treturn\n\t}\n\nOne guard in the shared function; rollbackVoiceJoin already re-runs updateKeyHolder and scopes the delete to state.JoinedAt.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0173",
|
||||
"title": "Setup wizard writes server_name/motd HTML-escaped while the admin Settings page writes them raw, so a first-run server name renders as literal ' entities",
|
||||
"file": "Server/admin/setup_wizard.go",
|
||||
"line": 104,
|
||||
"severity": "low",
|
||||
"why": "wizardValidateIdentity runs server_name and motd through the same bare `setupSanitizer.Sanitize`, and applyWizardSettings persists that escaped string into the settings table verbatim. The admin PATCH /admin/api/settings path (handlePatchSettings -> normalizeSettingUpdates) applies no sanitizer at all, so the two writers of the same two keys disagree: a name set at first run is stored escaped, the identical name set later in the Settings page is stored raw. The escaped copy is what the hub caches and ships in auth_ok, and the client renders it as text.",
|
||||
"repro": "Run the first-run wizard with server_name = \"Bob's Place\" and motd = \"Say \\\"hi\\\" & relax\". settings.server_name becomes \"Bob's Place\" and settings.motd becomes \"Say "hi" & relax\". Every client's connected overlay / server banner shows the literal entity text, and the overlay's one-letter server icon is derived from 'B' of the escaped string. Setting the identical values afterwards through the admin Settings page stores them unescaped, so the same input produces two different stored values depending on which path wrote it.",
|
||||
"evidence": "Server/admin/setup_wizard.go:104 name := strings.TrimSpace(setupSanitizer.Sanitize(*wr.ServerName))\nServer/admin/setup_wizard.go:114 motd := strings.TrimSpace(setupSanitizer.Sanitize(*wr.Motd))\nServer/admin/setup_wizard.go:203-208 updates[\"server_name\"] = *wr.ServerName ; updates[\"motd\"] = *wr.Motd\n\nvs. the other writer of the exact same keys, with no sanitizer anywhere in the file:\nServer/admin/handlers_settings.go:93-110 normalizeSettingUpdates — only require_2fa/registration_open are normalized\nServer/admin/handlers_settings.go:66-72 INSERT INTO settings (key, value) ... ON CONFLICT DO UPDATE SET value = excluded.value\n\nconsumer side:\nServer/ws/hub.go:172-176 refreshSettingsLocked reads settings server_name/motd into the hub cache\nServer/ws/serve_ready.go:19 buildAuthOK ships them to every client\nClient/tauri-client/src/components/ConnectedOverlay.ts:62 setText(srvIcon, serverName.charAt(0).toUpperCase())",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "flow-session",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "5b2211c5",
|
||||
"test": "Server/admin/setup_wizard_test.go",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Same one-line change as the username: in Server/admin/setup_wizard.go:104 and :114 use `service.SanitizeText(...)` instead of `setupSanitizer.Sanitize(...)`, so the wizard stores the same string the Settings page would. (Deleting setupSanitizer entirely once setup_handler.go:175 is also converted keeps the escaping sanitizer out of every storage path.)",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0174",
|
||||
"title": "Every automatic channel selection filters to type \"text\", stranding users whose only visible channels are announcement channels on a blank pane",
|
||||
"file": "Client/tauri-client/src/lib/dispatcher.ts",
|
||||
"line": 338,
|
||||
"severity": "low",
|
||||
"why": "Announcement channels are full message channels everywhere else — the server ships them with unread_count/last_message_id (Server/ws/serve_ready.go:226 `type == \"text\" || type == \"announcement\"`), computes can_send for them (channelCanSend), and the client renders them in the sidebar (ChannelSidebar.ts:253) and gives them a composer (ChannelController.ts:435). But all five automatic selection/fallback paths pick only `ch.type === \"text\"`, so when no text channel is visible the client calls setActiveChannel with nothing (or null) and shows an empty main pane even though readable channels exist in the sidebar.",
|
||||
"repro": "Give a role READ_MESSAGES on one announcement channel and deny it on every text channel (channel_overrides), or run an announcement-only server. Log in as that user: `ready` arrives with `currentActive === null` and `payload.channels.length > 0`, `payload.channels.find(ch => ch.type === \"text\")` is undefined, so setActiveChannel is never called and the app opens with no channel selected. Same in three sibling paths: dispatcher.ts:716 (active channel deleted → `firstTextId` is null → `setActiveChannel(null)` plus a \"This channel was deleted\" toast and an empty pane), dispatcher.ts:543 (last DM closed), SidebarArea.ts:474 and :565 (back out of DM mode). The unit test at tests/unit/dispatcher.test.ts:993 only asserts a *voice* channel is skipped in favour of a text one — nothing locks the exclusion of announcement, so this is not intended behaviour.",
|
||||
"evidence": "dispatcher.ts:338 const firstText = payload.channels.find((ch) => ch.type === \"text\");\ndispatcher.ts:543 .filter((ch) => ch.type === \"text\")\ndispatcher.ts:716 .filter((ch) => ch.type === \"text\")\nSidebarArea.ts:474 if (ch.type === \"text\") {\nSidebarArea.ts:565 if (ch.type === \"text\") {\n(vs Server/ws/serve_ready.go:226 if visibleChannels[i].Type == \"text\" || visibleChannels[i].Type == \"announcement\" {)",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "hotspot-server-ws",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "d066591a",
|
||||
"test": "Client/tauri-client/tests/unit/dispatcher.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Add one shared predicate next to the Channel type (e.g. `export const isTextLikeChannel = (ch: {type: ChannelType}) => ch.type === \"text\" || ch.type === \"announcement\";` in Client/tauri-client/src/lib/types.ts) and use it at dispatcher.ts:338, :543, :716 and SidebarArea.ts:474, :565 in place of the inline `ch.type === \"text\"` comparisons. DM channels are handled by separate branches and are unaffected.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0175",
|
||||
"title": "applySetChannelID's post-Subscribe revalidation re-checks READ but not `archived`, so a channel archived mid-focus leaves the socket permanently subscribed to it",
|
||||
"file": "Server/ws/handlers.go",
|
||||
"line": 265,
|
||||
"severity": "low",
|
||||
"why": "The OC-0024 comment on applySetChannelID explains that HandleChannelFocus's admission gate and this Subscribe are separated by SQLite round trips, so the subscription is re-validated afterwards. HandleChannelFocus refuses an archived channel (Server/service/channel.go:255-263, OC-0070), but the re-validation here only mirrors the DM-participant and READ_MESSAGES legs — it already has `ch` in hand and never looks at `ch.Archived`. Archiving is exactly a visibility change whose fan-out (RefreshChannelVisibility) can only Unsubscribe topics a socket holds at the instant it runs, so a Subscribe landing after it is never revisited.",
|
||||
"repro": "Client sends channel_focus(C) for a non-archived channel C. handleChannelFocusV2 → HandleChannelFocus reads C (not yet archived), passes the archived check, then runs GetLatestMessageID + GetReadState + UpdateReadState (UpdateReadState is a write that queues on the single SQLite writer). During that window an admin PATCHes C with archived=true (Server/admin/handlers_channels.go:229-271): AdminUpdateChannel commits, then hub.RefreshChannelVisibility(C) runs, takes the `case ch.Archived: visible = false` branch for every client, finds nothing subscribed for this user (the Subscribe has not happened yet) and c.channelID still 0, so it sends channel_delete and unsubscribes nothing. handleMessageApply then calls applySetChannelID(c, C): it sets c.channelID = C, Subscribes to ChannelTopic(C), re-reads C (now archived), takes the `else if ch != nil && hasChannelAccess(... ReadMessages)` branch — READ_MESSAGES is unaffected by archiving — and returns, leaving the subscription and focus in place. Result: the connection is focused on and subscribed to a channel the server has already told it was deleted and that every other surface (ready, VisibleChannelIDs, voice_join, channelReadAudience) hides, for the life of the socket.",
|
||||
"evidence": "Server/ws/handlers.go:257 ch, chErr := h.db.GetChannel(c.ctx, newChID)\nServer/ws/handlers.go:261 if ch != nil && ch.Type == \"dm\" { ... }\nServer/ws/handlers.go:265 } else if ch != nil && hasChannelAccess(c.ctx, h.db, h.permChecker, h.perms, c.userID, newChID, permissions.ReadMessages) {\nServer/ws/handlers.go:266 return // no ch.Archived check — HandleChannelFocus has one, this mirror does not",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "hotspot-server-ws",
|
||||
"finder": "opus",
|
||||
"confidence": "medium",
|
||||
"fix": {
|
||||
"commit": "eb70032d",
|
||||
"test": "Server/ws/handler_focus_revoke_race_test.go",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "One word in the shared applier: Server/ws/handlers.go:265 → `} else if ch != nil && !ch.Archived && hasChannelAccess(c.ctx, h.db, h.permChecker, h.perms, c.userID, newChID, permissions.ReadMessages) {`. The DM branch above needs no change (DMs carry no archived concept, matching HandleChannelFocus's DM exemption locked by TestHandleChannelFocus_DMExemptFromArchiveGate).",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0176",
|
||||
"title": "Tray Status menu sends presence_update directly, bypassing the shared presence rate limiter and its retry",
|
||||
"file": "Client/tauri-client/src/main.ts",
|
||||
"line": 263,
|
||||
"severity": "low",
|
||||
"why": "The tray's status-change handler calls ws.send({type:\"presence_update\"}) raw instead of going through the session's single PresenceSender. lib/presence.ts's module doc states every producer MUST share one PresenceSender built from one RateLimiter, because the server enforces a single 1-update/10s budget keyed by user id and silently drops the excess (no DB write, no broadcast, no retry). With the tray outside that budget, the tray's frame and any in-app frame within the same 10s window each believe they hold the token; whichever the server sees second is rejected and permanently lost.",
|
||||
"repro": "1. Sign in; MainPage builds one PresenceSender over limiters.presence (1 token / 10s).\n2. Right-click the tray icon -> Status -> \"Do Not Disturb\". main.ts:262 persists \"dnd\" locally and main.ts:263 sends presence_update raw. The server accepts it and opens its 10s window for this user. The client-side limiter's bucket is still EMPTY (it never saw this send).\n3. Within 10s, open the in-app status picker in the UserBar and choose \"Online\". UserBar.ts:177 -> presence.ts:56 limiter.tryConsume() returns TRUE (empty bucket), so ws.send fires immediately and NO retry is scheduled. presence.ts:52 has already applied the optimistic local update.\n4. The server rejects that frame with ErrRateLimited: no users.status write, no presence broadcast. Result: this client shows/behaves as \"online\", the server row and every other member's list still show \"dnd\", and nothing re-sends — the retry path that exists for exactly this (presence.ts:65-71) was never armed.\n\nThe reverse order fails the same way: pick a status in the UserBar (shared token consumed), then within 10s set a different one from the tray -> the tray's raw send is dropped by the server, while saveUserStatus() has already flipped the local pref, so notifications.ts:82 gates on the new DND while no peer ever learns of it.\n\nSecondary symptom on the same line: the tray path never calls updatePresence(), which every PresenceSender caller does (presence.ts:52), so the user's own row in membersStore is not updated either when the frame is dropped.\n\nThe desync only self-heals on the next reconnect (MainPage.ts:334 restoreSavedPresence), i.e. potentially for the whole session. No test locks this: tests/unit/main.test.ts:194-214 asserts only the saveUserStatus half of the tray handler, never the wire path.",
|
||||
"evidence": "main.ts:255-265\n void listen<string>(\"status-change\", (e) => {\n const status = e.payload;\n if (status === \"online\" || status === \"idle\" || status === \"dnd\" || status === \"offline\") {\n const mapped = status === \"offline\" ? \"invisible\" : status;\n saveUserStatus(mapped);\n ws.send({ type: \"presence_update\", payload: { status: mapped } }); // <-- raw send, no limiter, no retry, no updatePresence()\n }\n });\n\nContrast every other producer:\n pages/MainPage.ts:133 const presenceSender = createPresenceSender(ws, limiters.presence);\n pages/MainPage.ts:209 function applyPresence(status) { presenceSender.send(status); } // settings Account tab + auto-idle\n components/UserBar.ts:177 sender.send(status); // UserBar status picker\n\nlib/presence.ts:56-73 is the budget + retry the tray skips:\n if (limiter.tryConsume()) { ws.send({ type: \"presence_update\", payload: { status } }); }\n else { retry = setTimeout(() => { retry = null; send(loadUserStatus(), customStatus); }, limiter.getRemainingMs()); }\n\nServer/service/channel.go:171-174 (the budget being blown):\n ratKey := auth.Key(\"presence\", userID)\n if limiter != nil && !limiter.Allow(ratKey, 1, 10*time.Second) { return nil, ErrRateLimited }\n\nThe rejection reaches the client only as a generic error frame (Server/ws/handlers_chat.go:171-172 -> ErrCodeRateLimited). lib/dispatcher.ts:1035-1060 only routes an error id to a pending optimistic *message* send; a presence frame has no pendingSends entry, so the branch falls through to a toast and nothing re-sends the status.",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "hotspot-client-tauri-client-src-lib",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "b9b86067",
|
||||
"test": "Client/tauri-client/tests/unit/main.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Give main.ts access to the session's single PresenceSender instead of adding a second budget. Smallest shape: add a module-level slot in Client/tauri-client/src/lib/presence.ts (e.g. `let active: PresenceSender | null = null; export function setActivePresenceSender(s: PresenceSender | null) { active = s; } export function getActivePresenceSender() { return active; }`), have MainPage.ts:133 call `setActivePresenceSender(presenceSender)` right after constructing it and `setActivePresenceSender(null)` next to `presenceSender.destroy()` in its teardown (MainPage.ts:804), then replace main.ts:263 with `getActivePresenceSender()?.send(mapped);`. saveUserStatus(mapped) on line 262 stays exactly as is, so OC-0037's assertions still pass, and the tray now shares the one token bucket, the coalescing retry, and the optimistic updatePresence with every other producer. When no session is mounted the optional call is a no-op, matching today's \"ws.send is a safe no-op\" behavior.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0177",
|
||||
"title": "The voice roster is the only identity surface that ignores nicknames — VoiceUser carries no display name at all",
|
||||
"file": "Client/tauri-client/src/stores/voice.store.ts",
|
||||
"line": 253,
|
||||
"severity": "low",
|
||||
"why": "`VoiceUser` (voice.store.ts:17-31) has a `username` field and no `displayName`, and every writer fills it with the raw handle: `setVoiceStates` uses `member?.username` (line 165), `updateVoiceState` uses `payload.username` (line 226), and `updateVoiceUserProfile` only accepts `{ username }` (line 253), which is all `dispatcher.ts:813` passes on `user_update`. Every other surface renders the nickname — `memberDisplayName` for the member list, `resolveAuthor` for message rows, `dmDisplayName` for the DM sidebar — so the same person appears under two different names at the same time.",
|
||||
"repro": "User Bob sets display_name = \"Bobby\" (PATCH /profile). The member list, his message rows, the profile popup and any DM row all render \"Bobby\". Bob joins a voice channel: `ChannelSidebar.ts:410` renders `createElement(\"span\", {class:\"vu-name\"}, user.username || \"Unknown\")` from `VoiceUser.username`, so the voice roster (and the moderation context menu at ChannelSidebar.ts:489/532, and the identity-mismatch modal at :471) shows \"Bob\". Renaming to \"Bobby\" mid-call changes every other surface and leaves the voice row unchanged, because `updateVoiceUserProfile` has no nickname to write.",
|
||||
"evidence": "voice.store.ts:253 export function updateVoiceUserProfile(userId: number, patch: { readonly username: string }): void\nvoice.store.ts:165 username: member?.username ?? \"\",\nChannelSidebar.ts:410 const nameEl = createElement(\"span\", { class: \"vu-name\" }, user.username || \"Unknown\");\nmembers.store.ts:182 export function memberDisplayName(member: ...): string // \"The one place that answers it, so the member list, message rows and the profile popup cannot disagree.\"",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-1",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "81b22f56",
|
||||
"test": "Client/tauri-client/tests/unit/channel-sidebar.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Resolve at render time in ChannelSidebar.ts:410 — it already imports membersStore (line 29): `const m = membersStore.getState().members.get(user.userId); const label = (m ? memberDisplayName(m) : user.username) || \"Unknown\";` and use `label` for the .vu-name text (leave the E2EE mismatch modal and moderation menu on user.username). One call site, no store/type/protocol change, and the \"Unknown\" empty-username test still passes.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0178",
|
||||
"title": "The typing indicator prints raw usernames instead of nicknames, despite holding full Member objects",
|
||||
"file": "Client/tauri-client/src/components/TypingIndicator.ts",
|
||||
"line": 20,
|
||||
"severity": "low",
|
||||
"why": "`formatTypingText` receives `readonly Member[]` — the exact type `memberDisplayName()` in members.store.ts:182 exists to render — and reads `.username` directly. members.store's own doc calls that helper \"the one place that answers it, so the member list, message rows and the profile popup cannot disagree\"; this call site disagrees.",
|
||||
"repro": "Bob has display_name \"Bobby\". He starts typing in #general. `getTypingUsers` returns his `Member` (which carries `displayName: \"Bobby\"`), but the bar renders \"Bob is typing...\" while his previous message directly above it is attributed to \"Bobby\" by `resolveAuthor` (formatting.ts:139). Two names for one person on the same screen.",
|
||||
"evidence": "TypingIndicator.ts:18 function formatTypingText(users: readonly Member[]): string {\nTypingIndicator.ts:20 return `${users[0]?.username ?? \"Someone\"} is typing...`;\nmembers.store.ts:182 export function memberDisplayName(member: Pick<Member, \"username\" | \"displayName\">): string",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-1",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "e2ae20c0",
|
||||
"test": "Client/tauri-client/tests/unit/components/TypingIndicator.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "In TypingIndicator.ts, import memberDisplayName from @stores/members.store and replace `users[0]?.username` / `users[1]?.username` with `users[0] ? memberDisplayName(users[0]) : undefined` (same for [1]), keeping the `?? \"Someone\"` fallback. Two expressions in one function; the helper already handles blank/whitespace displayName.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0179",
|
||||
"title": "onDeafenToggle's undeafen path is missing the localServerMuted guard its sibling onMuteToggle has, so a server-muted user gets an error toast every time they undeafen",
|
||||
"file": "Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts",
|
||||
"line": 96,
|
||||
"severity": "low",
|
||||
"why": "onMuteToggle refuses to send an unmute while `localServerMuted === true` (\"the server refuses the unmute, so don't spend the round-trip\"), but the undeafen branch of onDeafenToggle unconditionally calls voiceSessionSetMuted(false) and sends `voice_mute {muted:false}`. The session-layer guard in LiveKitSession.setMuted (livekitSession.ts:1591) swallows the local half, so state stays consistent — but the frame still goes to the server, which refuses it in refuseIfServerSilenced (Server/ws/voice_controls.go:273) with SERVER_MUTED and no broadcast. The dispatcher's catch-all error branch turns that into a user-visible error toast for an action that actually succeeded.",
|
||||
"repro": "1. Moderator issues voice_mod_mute on user U (server_muted=1, server_deafened=0); U's client sets localMuted=true, localServerMuted=true.\n2. U self-deafens (allowed — refuseIfServerSilenced only consults ServerDeafened for the deafen direction). onDeafenToggle takes the else branch; localMuted is already true so no voice_mute is sent.\n3. U un-deafens. localServerDeafened is false so the guard at line 92 passes; the voice_deafen frame is accepted, then line 96/97 fire.\n4. voiceSelfToggleV2 -> refuseIfServerSilenced(deafen=false) sees ServerMuted -> returns ClientError{SERVER_MUTED, \"you were muted by a moderator\"} with no voice_state broadcast.\n5. dispatcher.ts's S.ERROR handler falls through every correlated branch (no envelope id on this send, voiceStatus is \"connected\", code is neither CHANNEL_FULL nor VIDEO_LIMIT) and reaches showToast(payload.message, \"error\") — U sees \"you were muted by a moderator\" as an error toast for a successful undeafen, every single time. No test covers this: tests/unit/voice-callbacks.test.ts's makeVoiceState fixture never sets localServerMuted.",
|
||||
"evidence": "onDeafenToggle: () => {\n if (!limiters.voice.tryConsume()) return;\n const state = voiceStore.getState();\n if (state.localServerDeafened === true) return; // guards deafen only\n if (state.localDeafened) {\n voiceSessionSetDeafened(false);\n ws.send({ type: \"voice_deafen\", payload: { deafened: false } });\n voiceSessionSetMuted(false); // no localServerMuted check\n ws.send({ type: \"voice_mute\", payload: { muted: false } });\n\n// contrast, onMuteToggle (same file, line 76):\n// if (state.localServerMuted === true) return;",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-2",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "5c0606bf",
|
||||
"test": "Client/tauri-client/tests/unit/voice-callbacks.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Mirror onMuteToggle's guard on the unmute half of the undeafen branch in Client/tauri-client/src/pages/main-page/VoiceCallbacks.ts:96-97 — wrap those two lines in `if (state.localServerMuted !== true) { ... }` so no voice_mute{muted:false} frame is sent while the moderator mute stands (the deafen clear itself still goes through).",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0180",
|
||||
"title": "Avatar URL is HTML-escaped by the bare bluemonday sanitizer, corrupting every avatar URL with a query string",
|
||||
"file": "Server/api/profile_handler.go",
|
||||
"line": 202,
|
||||
"severity": "low",
|
||||
"why": "handleUpdateProfile fixed the username path to use service.SanitizeText precisely because bluemonday's output is always HTML-escaped, but the avatar branch three lines below still calls the bare sanitizer.Sanitize. `&` becomes `&` (and `'` becomes `'`), so any https avatar URL carrying more than one query parameter is persisted mangled. validateAvatarURL still parses it fine, so the write succeeds and the corruption is silent.",
|
||||
"repro": "PATCH /api/v1/users/me with {\"username\":\"alice\",\"avatar\":\"https://www.gravatar.com/avatar/abc?s=256&d=identicon\"}. sanitizer.Sanitize rewrites it to \"https://www.gravatar.com/avatar/abc?s=256&d=identicon\"; url.Parse accepts it, so it is stored and shipped in ready/member payloads. Every client then requests a URL with a literal `&` parameter and the avatar fails to load. Same for any presigned/CDN URL (`?size=128&quality=lossless`).",
|
||||
"evidence": "// Use the fixpoint sanitizer (service.SanitizeText), not the bare\n// sanitizer.Sanitize below — Sanitize's output is always HTML-escaped\nreq.Username = strings.TrimSpace(service.SanitizeText(req.Username))\n...\nif req.Avatar != nil {\n\ttrimmed := strings.TrimSpace(sanitizer.Sanitize(*req.Avatar))\n\tif err := validateAvatarURL(trimmed); err != nil {",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "hotspot-server-admin",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "78b81b1b",
|
||||
"test": "Server/api/profile_handler_test.go",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Server/api/profile_handler.go:202 — replace `sanitizer.Sanitize(*req.Avatar)` with `service.SanitizeText(*req.Avatar)`, matching the username path at line 186 (service is already imported).",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0181",
|
||||
"title": "Settings overlay opened for the first time from the Connect page never moves focus into the dialog, and its focus trap is inert",
|
||||
"file": "Client/tauri-client/src/components/SettingsOverlay.ts",
|
||||
"line": 393,
|
||||
"severity": "low",
|
||||
"why": "`mount()` runs the initial `show()` (which calls `focusDialog(panel)`) at line 393, but the overlay's `root` is not attached to the document until line 397. `HTMLElement.focus()` on a detached subtree is a no-op, so neither the first focusable control nor the panel itself receives focus. Because `trapFocus` is registered on `panel`, keydown events from the still-focused element outside the panel never reach the trap, so Tab walks freely through the page underneath the full-screen overlay.",
|
||||
"repro": "ConnectPage creates the SettingsOverlay lazily: `ensureSettingsOverlay()` only runs once `uiStore.settingsOpen` is already true (ConnectPage.ts:256 and :259), and then calls `settingsOverlay.mount(root)` (ConnectPage.ts:243). So on the very first click of the Settings gear on the connect page, `SettingsOverlay.mount()` executes with `uiStore.getState().settingsOpen === true`: line 375 `renderActiveTab()`, line 393-395 `show()` -> `restoreFocus = focusDialog(panel)` while `root` is still detached (`queryFocusable(panel)[0].focus()` does nothing), and only at line 397 `container.appendChild(root)`. Result: the Settings panel is visible, `document.activeElement` is still the connect-page host input (ConnectPage.mount ends with `loginForm.focusHost()`), Tab cycles through the hidden login form instead of the dialog, and a screen reader is never moved into the dialog. Every later open goes through the uiStore subscription with `root` already attached and works correctly — which is why tests/unit/settings-overlay.test.ts:839 (`mount` then `open()`) passes and does not cover this path. MainPage is unaffected because it mounts the overlay while `settingsOpen` is false.",
|
||||
"evidence": "// Sync initial state\n if (uiStore.getState().settingsOpen) {\n show();\n }\n\n container.appendChild(root); // <- root is only attached AFTER show()/focusDialog(panel)",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-1",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "9e8719dc",
|
||||
"test": "Client/tauri-client/tests/unit/settings-overlay.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Move the attach above the initial sync in mount(): do `container.appendChild(root);` first, then `if (uiStore.getState().settingsOpen) show();`. One reorder in the shared mount() fixes every caller.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0182",
|
||||
"title": "Member profile popup shows a stale presence status after an in-place presence patch",
|
||||
"file": "Client/tauri-client/src/components/MemberList.ts",
|
||||
"line": 248,
|
||||
"severity": "low",
|
||||
"why": "`createMemberItem` closes over the `Member` object captured at render time, and `updatePresence` (members.store.ts:170) replaces that object with a new one instead of mutating it. A presence-only change takes the `patchPresence` fast path (MemberList.ts:434-452), which repaints only the row's dot/`offline` class and never rebuilds the row — so the click handler keeps handing `UserProfilePopup` the *old* `status`. The dot and the popup, both driven from `membersStore`, disagree indefinitely.",
|
||||
"repro": "Open the member list with Bob offline. Bob connects → server sends a presence frame → `updatePresence(bobId, \"online\")`. `isPresenceOnlyChange` is true (username/role/avatar/displayName/customStatus/identityPublicKey all unchanged), so `patchPresence` recolors Bob's dot green in place and no row is rebuilt. Left-click Bob's row: the profile popup renders a grey dot and the label \"Offline\" next to the green dot in the list behind it. It stays wrong until some *structural* member change (a join/leave, a role change, a roles_update) forces `renderList`. tests/unit/member-list.test.ts:525-566 only asserts the dot/class patch and row identity — nothing locks the popup's status, so this is not intended behavior.",
|
||||
"evidence": "MemberList.ts:471-475 — `if (isPresenceOnlyChange(prevMembers, members)) { patchPresence(prevMembers, members, rowsByUserId); } else { renderList(...) }`\nMemberList.ts:243-251 — `activePopup = createUserProfilePopup({ user: { … status: member.status, … } })` where `member` is the argument captured by `createMemberItem(member, …)` at line 190/396.\nmembers.store.ts:169-174 — `const next = new Map(prev.members); next.set(userId, { ...existing, status, … });` (new object each time).\nUserProfilePopup.ts:169/238/239 render `STATUS_COLORS[user.status]` / `STATUS_LABELS[user.status]` from that captured snapshot.",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-2",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "9fedef5b",
|
||||
"test": "Client/tauri-client/tests/unit/member-list.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Resolve the live member inside the click handler instead of using the render-time snapshot: at MemberList.ts:242 insert `const live = membersStore.getState().members.get(member.id) ?? member;` and build the popup's `user` from `live` (at minimum `status: live.status`). One change in createMemberItem covers every row.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0183",
|
||||
"title": "platformInit's page-count conversion overflows uint32, so plugins.max_memory_mb >= 4096 silently yields a 0-page (or far smaller) memory limit",
|
||||
"file": "Server/plugin/sandbox_wazero.go",
|
||||
"line": 87,
|
||||
"severity": "low",
|
||||
"why": "`uint32(memMB) * 1024 * 1024 / wazeroPageBytes` does the whole multiplication in uint32, which wraps at 4 GiB. `plugins.max_memory_mb` is an unbounded `int` from the config file (config.go:126, default 64) with no upper-bound validation anywhere, so any value at or above 4096 wraps to a limit far below what was asked for — and exactly 4096 (or 8192, …) wraps to zero.",
|
||||
"repro": "Set `plugins.max_memory_mb: 4096` in the server config and start with `-tags wazero`. `uint32(4096) * 1024 * 1024` == 2^32 == 0 in uint32, so `memPages` is 0 and the shared runtime is built with `WithMemoryLimitPages(0)`. Every plugin whose WASM declares a memory section (i.e. every plugin with a usable JSON ABI) then fails `rt.InstantiateModule`, `activateWithRuntime` returns the wrapped instantiate error, and `activateAll` (registry.go:508) only logs `\"plugin: activation failed\"` per plugin — the server starts with all plugins silently dark. With `plugins.max_memory_mb: 5000` the same wrap yields 14464 pages (~904 MiB) instead of 5000 MiB, again with no warning.",
|
||||
"evidence": "\tmemMB := cfg.MaxMemoryMB\n\tif memMB <= 0 {\n\t\tmemMB = 64 // default 64 MiB per plugin runtime\n\t}\n\tmemPages := uint32(memMB) * 1024 * 1024 / wazeroPageBytes\n\n\trt := wazero.NewRuntimeWithConfig(ctx,\n\t\twazero.NewRuntimeConfig().\n\t\t\tWithMemoryLimitPages(memPages).",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-3",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "549088d5",
|
||||
"test": "Server/plugin/sandbox_wazero_test.go",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Compute in 64-bit and clamp to wasm32's page ceiling before narrowing: `pages := uint64(memMB) * 1024 * 1024 / wazeroPageBytes; if pages > 65536 { pages = 65536 }; memPages := uint32(pages)` — one change in platformInit, no caller changes.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0184",
|
||||
"title": "urlEnd gives back a trailing single '*' but not a trailing single '_', so `_https://…_` never italicizes",
|
||||
"file": "Client/tauri-client/src/components/message-list/markdown.ts",
|
||||
"line": 99,
|
||||
"severity": "low",
|
||||
"why": "The trailing-delimiter give-back has two clauses: a regex that only matches runs of two or more (`([*_~|])\\1+$`) and a hand-written special case for exactly one `*`. `_` is the other single-character emphasis marker in DELIMS, and it has no such clause — so a bare URL swallows the closing `_` and `scanClose` never finds it, which is precisely the failure the comment above the function says it exists to prevent.",
|
||||
"repro": "`parseInline(\"*https://example.com/a*\")` → `[{type:\"em\", children:[{type:\"text\", value:\"https://example.com/a\"}]}]` (urlEnd stops at index 21 via the `src[end-1] === \"*\"` clause, scanClose finds the closer). `parseInline(\"_https://example.com/a_\")` → `[{type:\"text\", value:\"_https://example.com/a_\"}]`: urlEnd's tail is `\"example.com/a_\"`, the run regex needs two or more so it does not match, the next clause only tests for `\"*\"`, so urlEnd returns end-of-string, scanClose returns -1 and the emphasis is dropped. The user sees literal underscores around the link. `~~url~~`, `||url||`, `__url__` and `**url**` all work, only the single `_` form does not.",
|
||||
"evidence": " const min = i + 8;\n for (;;) {\n const tail = src.slice(min, end);\n const run = /([*_~|])\\1+$/.exec(tail);\n if (run !== null) {\n end -= run[0].length;\n continue;\n }\n if (end > min && src[end - 1] === \"*\") {\n end--;\n continue;\n }\n break;\n }",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-3",
|
||||
"finder": "opus",
|
||||
"confidence": "medium",
|
||||
"fix": {
|
||||
"commit": "fd09f4e6",
|
||||
"test": "Client/tauri-client/tests/unit/content-markdown.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Widen the single-character clause to the other one-character emphasis marker: `if (end > min && (src[end - 1] === \"*\" || src[end - 1] === \"_\")) { end--; continue; }` at markdown.ts:99. The doubled-marker cases still short-circuit through the run regex above it, so `__url__` and `https://x/a_b_c` are unchanged.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0185",
|
||||
"title": "Every message's hover action bar (React/Reply/Pin/Edit/Delete/Copy link) is invisible to keyboard users while still sitting in the tab order",
|
||||
"file": "Client/tauri-client/src/styles/app.css",
|
||||
"line": 2160,
|
||||
"severity": "low",
|
||||
"why": "`.msg-actions-bar` is revealed only by `.message:hover`. There is no `:focus-within` (or `:focus`) rule anywhere in src/styles — `grep -rn \"focus-within\" src/styles` returns exactly one hit, `.message-input-box:focus-within`. The children are real `<button>` elements with no `tabindex=\"-1\"`, so Tab focuses them; they render at `opacity: 0`, so the focus ring is invisible and the user has no idea where focus is or what will fire on Enter.",
|
||||
"repro": "Open a channel with 20 messages, click into the message list, and press Tab repeatedly without moving the mouse. Focus walks into `.msg-actions-bar` and steps through 4-6 buttons per message (`msg-react-*`, `msg-reply-*`, `msg-pin-*`, `msg-edit-*`, `msg-delete-*`, `msg-copy-link-*`, created at renderers.ts:301-360) with nothing visible on screen. Pressing Enter on the third invisible stop deletes a message. Adding `.message:focus-within .msg-actions-bar { opacity: 1; pointer-events: auto; }` makes them appear.",
|
||||
"evidence": "app.css:2150-2170:\n.msg-actions-bar {\n position: absolute; ...\n opacity: 0; /* line 2160 */\n pointer-events: none; /* line 2161 */\n ...\n}\n.message:hover .msg-actions-bar { /* line 2166 - the ONLY reveal rule */\n opacity: 1;\n pointer-events: auto;\n}\n\nrenderers.ts:301-311 creates them as focusable <button>s:\n const reactBtn = createElement(\"button\", { \"data-testid\": `msg-react-${msg.id}`, \"aria-label\": \"React\" });\n reactBtn.addEventListener(\"click\", () => opts.onReactionClick(msg.id, \"\"), { signal });",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-1",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "2dd2446a",
|
||||
"test": "Client/tauri-client/tests/unit/msg-actions-bar-focus-css.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Add one rule next to the hover rule at app.css:2166: `.message:focus-within .msg-actions-bar { opacity: 1; pointer-events: auto; }` (keeping the existing hover selector), so a focused action button is visible wherever it can be activated.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0186",
|
||||
"title": "Reaction pills are put in the tab order but have no keyboard activation, so a reaction cannot be toggled without a pointer",
|
||||
"file": "Client/tauri-client/src/components/message-list/reactions.ts",
|
||||
"line": 23,
|
||||
"severity": "low",
|
||||
"why": "The chip is given `tabindex=\"0\"` and a `click` listener, but no `role` and no `keydown` handler. A `<span>` gets no native key activation, so Enter/Space on a focused chip does nothing. The sibling `+` add-reaction chip (line 48) has no `tabindex` at all, so it is not even reachable. QuickSwitchOverlay.ts:110-119 shows the codebase's own pattern for exactly this case (role=\"button\" + an Enter/Space keydown mirroring the click).",
|
||||
"repro": "Focus a reaction pill with Tab (focusin fires `attachReactionTooltip`'s `start`, reaction-tooltip.ts:310, so the who-reacted tooltip does appear — proving the chip is reachable). Press Enter, then Space: `opts.onReactionClick(msg.id, reaction.emoji)` is never invoked and the reaction is not toggled. Continuing to Tab never reaches the `+` chip, which carries no tabindex, so the emoji picker cannot be opened from the pill row either.",
|
||||
"evidence": "reactions.ts:20-35:\n const chip = createElement(\"span\", {\n class: reaction.me ? \"reaction-chip me\" : \"reaction-chip\",\n // Focusable so the who-reacted tooltip is reachable without a pointer.\n tabindex: \"0\",\n \"data-emoji\": reaction.emoji,\n });\n ...\n chip.addEventListener(\"click\", () => opts.onReactionClick(msg.id, reaction.emoji), { signal });\n // no keydown listener, no role=\"button\"\n\nreactions.ts:48-49 (add button, not focusable at all):\n const addBtn = createElement(\"span\", { class: \"reaction-chip add-reaction\" }, \"+\");\n addBtn.addEventListener(\"click\", () => opts.onReactionClick(msg.id, \"\"), { signal });",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-1",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "f037dfdd",
|
||||
"test": "Client/tauri-client/tests/unit/reactions-keyboard.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "In reactions.ts, give the chip role=\"button\" in the createElement attrs and add, next to the existing click listener, `chip.addEventListener(\"keydown\", (e) => { if (e.key === \"Enter\" || e.key === \" \") { e.preventDefault(); opts.onReactionClick(msg.id, reaction.emoji); } }, { signal });` — and apply the same three attributes/listener to the add-reaction chip at line 48 so the picker is reachable too.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0187",
|
||||
"title": "\"Add Server\" host validator rejects every IPv6 literal that the rest of the client accepts",
|
||||
"file": "Client/tauri-client/src/pages/connect-page/ServerPanel.ts",
|
||||
"line": 310,
|
||||
"severity": "low",
|
||||
"why": "ServerPanel's Add Server modal validates the address with `/^[\\w.-]+(:\\d+)?$/`, which matches no IPv6 form (no brackets, no colons beyond one host:port separator). `api.ts`'s `isValidHost` — the validator every real connection goes through — was extended with two explicit IPv6 branches (bracketed `[::1]:8443` and bare `2001:db8::1`), and the Rust proxies (`http_proxy.rs`/`livekit_proxy.rs` `validate_remote_host`/`parse_server_name`) accept them too. ServerPanel is the only other host regex in the client and never got the sibling branches, so an IPv6 server can be logged into by typing the address into the login form (`LoginForm.ts` `hostInput` is free text with no pattern check) but can never be saved as a server profile.",
|
||||
"repro": "Open the connect page → Servers panel → \"+ Add Server\". Enter name \"v6\" and Host Address \"[::1]:8443\" (or \"2001:db8::1\"). Click \"Add Server\": `handleSave` fails the regex, calls `hostAddrInput.setCustomValidity(\"Invalid server address (expected host or host:port)\")` + `reportValidity()` and returns — the profile is never created and `onAddProfile` is never called. Typing that exact same address into the login form's Server Address field and pressing Connect succeeds, because `api.setConfig`'s `isValidHost` (api.ts:85-91) accepts both bracketed and bare IPv6. Net effect: IPv6 servers are reachable but unsaveable, so auto-login/health-check/profile list never work for them. No test locks the regex (tests/unit/server-panel.test.ts contains no address-validation case).",
|
||||
"evidence": "ServerPanel.ts:309-315\n // Validate address: must be a valid hostname:port — no paths, no special chars\n if (!/^[\\w.-]+(:\\d+)?$/.test(addr)) {\n hostAddrInput.setCustomValidity(\"Invalid server address (expected host or host:port)\");\n hostAddrInput.reportValidity();\n return;\n }\n\nvs api.ts:81-94\n function isValidHost(host: string): boolean {\n if (host.length > 253) return false;\n if (/^\\[[0-9A-Fa-f:.]+\\](:\\d+)?$/.test(host)) return true;\n if ((host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host)) return true;\n return /^[\\w.-]+(:\\d+)?$/.test(host);\n }",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-2",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "3f2d1add",
|
||||
"test": "Client/tauri-client/tests/unit/server-panel.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Stop duplicating the regex: export the existing isValidHost (currently a closure inside createApiClient in src/lib/api.ts:81-94) into a shared module and call it from ServerPanel.handleSave, so the modal and the connection path share one gate. If a lift is unwanted, at minimum add the same two branches ahead of the DNS/IPv4 test at ServerPanel.ts:310: `/^\\[[0-9A-Fa-f:.]+\\](:\\d+)?$/.test(addr)` and `(addr.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(addr)`.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0188",
|
||||
"title": "Renaming your username overwrites the profile card's display-name header with the raw username",
|
||||
"file": "Client/tauri-client/src/components/settings/AccountTab.ts",
|
||||
"line": 1151,
|
||||
"severity": "low",
|
||||
"why": "`headerName` is the `.account-header-name` slot, built from `resolveDisplayName({username, displayName})` — i.e. it shows the nickname when one is set. The username-rename success path writes the new *username* into both `headerName` and `usernameValue`, ignoring the display name entirely. Its sibling save path in the same file (`buildProfileFields`) gets this right: `onSaved(displayName.length > 0 ? displayName : username)`. So the two writers of the same DOM node disagree, and the rename path desyncs the card from the actual profile state until the tab is rebuilt.",
|
||||
"repro": "Settings → Account. Set Display Name to \"Alice Smith\" and press \"Save Profile\" — the card header correctly reads \"Alice Smith\". Now press \"Edit\" next to Username, type \"alice2\", press Save. `onUpdateProfile({username:\"alice2\"})` resolves (the server merges, leaving display_name = \"Alice Smith\" untouched) and line 1151 runs `setText(headerName, \"alice2\")`. The card header now reads \"alice2\" even though the stored display name is still \"Alice Smith\", and every other surface (UserBar, message list, member list) keeps showing \"Alice Smith\". Switching to another settings tab and back re-runs `buildAccountTab`, which restores \"Alice Smith\" — confirming the header was wrong, not the data.",
|
||||
"evidence": "AccountTab.ts:1147-1154\n void options\n .onUpdateProfile({ username: newName })\n .then(() => {\n setText(headerName, newName);\n setText(usernameValue, newName);\n editForm.style.display = \"none\";\n })\n\ncompare AccountTab.ts:70 (header source) and :275-277 (the correct sibling)\n const headerName = createElement(\"div\", { class: \"account-header-name\" }, displayName);\n onSaved(\n displayName.length > 0 ? displayName : (authStore.getState().user?.username ?? \"\"),\n );",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-2",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "2ce5879c",
|
||||
"test": "Client/tauri-client/tests/unit/settings-overlay.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "At AccountTab.ts:1151 resolve the same way the initial build does instead of writing the raw username: `setText(headerName, resolveDisplayName({ username: newName, displayName: authStore.getState().user?.display_name ?? null }));` (resolveDisplayName is already imported at :11, and updateUser has run before this .then, so the store is fresh). Leave :1152 (usernameValue) as is.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0189",
|
||||
"title": "Embedded DM section drops mentionCount entirely, so an @mention in a muted DM is invisible in the default sidebar",
|
||||
"file": "Client/tauri-client/src/pages/main-page/SidebarDmSection.ts",
|
||||
"line": 128,
|
||||
"severity": "low",
|
||||
"why": "`renderDmListItems` renders only `dm.unreadCount` and computes the header aggregate as `sum + (isChannelMuted(c.channelId) ? 0 : c.unreadCount)`. `DmChannel.mentionCount` is never read. Both sibling surfaces do the opposite: `ChannelSidebar.ts:280` and `DmSidebar.ts:201` render a separate mention badge that a mute deliberately does NOT dim or suppress, and `lib/channel-mutes.ts` states the policy explicitly (\"a message that mentions you STILL notifies and still shows the red mention badge... a mute that swallowed a direct mention would be a mute nobody could safely use\"). In channels mode — the default sidebar — a mute therefore silences exactly the thing the policy says it must never silence.",
|
||||
"repro": "Mute a 1:1 DM (DM sidebar row context menu -> Mute). Switch the sidebar back to \"channels\" mode. Have the other user send a message that @-mentions you. The DM's mentionCount and unreadCount both increment in dmStore, but: (a) the row badge is the plain dimmed unread badge, never the red mention badge; (b) the DIRECT MESSAGES header badge adds 0 for that channel because the reduce at line 129 zeroes every muted conversation regardless of mentions. Result: no mention indicator anywhere in the default sidebar. Do the same with the same DM open in DM-sidebar mode and DmSidebar.ts:201 correctly shows the red `dm-mention-badge`.",
|
||||
"evidence": "line 97: if (dm.unreadCount > 0) { ... class: muted ? \"dm-unread-badge muted\" : \"dm-unread-badge\" ... }\nline 128: const totalUnread = dmChannels.reduce(\nline 129: (sum, c) => sum + (isChannelMuted(c.channelId) ? 0 : c.unreadCount),\n// vs DmSidebar.ts:196 \"A muted conversation dims the unread badge but NOT the mention badge\"\n// vs ChannelSidebar.ts:271 \"mention badge is deliberately left alone: a mute silences chatter, never...\"",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-3",
|
||||
"finder": "opus",
|
||||
"confidence": "medium",
|
||||
"fix": {
|
||||
"commit": "bc58ebc8",
|
||||
"test": "Client/tauri-client/tests/unit/sidebar-dm-section.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "In SidebarDmSection.ts mirror DmSidebar's precedence: render a `dm-mention-badge` when `dm.mentionCount > 0` (else the existing unread badge), and change the header reduce to `sum + (isChannelMuted(c.channelId) ? c.mentionCount : c.unreadCount)` so a mute drops chatter but not mentions.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0190",
|
||||
"title": "adminPanelUrl does not bracket a bare IPv6 host, producing an unopenable URL for the Audit Log button",
|
||||
"file": "Client/tauri-client/src/lib/admin-panel.ts",
|
||||
"line": 20,
|
||||
"severity": "low",
|
||||
"why": "`https://${host}/admin` interpolates the stored host verbatim. The client explicitly supports unbracketed IPv6 server hosts: `api.ts:87-91` accepts `::1` / `2001:db8::1`, `livekitSession.ts:763` wraps bare IPv6 in brackets before building a URL, and `http_proxy.rs::resolve_remote_target` normalizes `2001:db8::1` to `[2001:db8::1]:443`. admin-panel.ts is the one URL-building site that skips that normalization, so it emits `https://2001:db8::1/admin#audit`, which is not a valid absolute URL (RFC 3986 requires brackets around an IPv6 literal authority).",
|
||||
"repro": "Connect to a server by typing a bare IPv6 address at the connect screen (e.g. `::1` or `2001:db8::1` — accepted by api.ts isValidHost line 91, and REST/WS/LiveKit all work because the Rust proxies bracket it themselves). As a moderator with VIEW_AUDIT_LOG, click the sidebar's \"Audit Log\" button (SidebarArea.ts:214). openAdminPanel builds `https://::1/admin#audit` and hands it to `openUrl`; the browser cannot resolve that authority. The affordance is broken on exactly the hosts the rest of the client supports; with a bracketed host (`[::1]:8443`) the same click works.",
|
||||
"evidence": "admin-panel.ts:20 const base = `https://${host}/admin`;\n// vs livekitSession.ts:763-765\n// } else if ((this.serverHost.match(/:/g) ?? []).length > 1) {\n// // Bare IPv6 (multiple colons) — wrap in brackets and add default port\n// hostWithPort = `[${this.serverHost}]:443`;\n// and api.ts:91 if ((host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host)) return true;",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-3",
|
||||
"finder": "opus",
|
||||
"confidence": "medium",
|
||||
"fix": {
|
||||
"commit": "204b333a",
|
||||
"test": "Client/tauri-client/tests/unit/admin-panel.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Normalize inside adminPanelUrl before interpolation, reusing the existing convention: `const authority = !host.startsWith(\"[\") && (host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host) ? `[${host}]` : host;` then build `https://${authority}/admin`.",
|
||||
"fixedDate": "2026-08-19"
|
||||
},
|
||||
{
|
||||
"id": "OC-0191",
|
||||
"title": "Focus trap treats disabled controls as focusable, so Tab escapes the dialog while a modal's submit button is in flight",
|
||||
"file": "Client/tauri-client/src/lib/a11y.ts",
|
||||
"line": 12,
|
||||
"severity": "low",
|
||||
"why": "FOCUSABLE_SELECTOR matches `button`/`input`/`select` regardless of the `disabled` attribute, and `isFocusable` filters only on inline display/visibility. A disabled control can never be `document.activeElement`, so when it is the computed `last` (or `first`) the wrap comparison in `trapFocus` never matches and no preventDefault happens. Worse, disabling a control that currently holds focus blurs it to `document.body`, which is outside `container` — the keydown listener is bound to `container`, so Tab is not even seen by the trap and falls through to native document order.",
|
||||
"repro": "Open the Create Channel modal (CreateChannelModal.ts). Document-order focusables inside `.modal` are: closeBtn, categoryInput, nameInput, typeSelect, cancelBtn, createBtn — so `last` === createBtn. Type a name and click \"Create Channel\" against a slow/unreachable server: line 163 sets `disabled`, the browser blurs the button, activeElement becomes body. Press Tab — the trapFocus keydown handler on `modal` never fires (target is body) and focus lands in the sidebar/message list behind the still-open modal. Even if focus is manually put back on cancelBtn, Tab does not wrap, because `active === last` compares against the disabled createBtn. Same path in EditChannelModal.ts:299 and DeleteChannelModal.ts:88.",
|
||||
"evidence": "a11y.ts:12-13 const FOCUSABLE_SELECTOR =\n 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])';\na11y.ts:26 return el.style.display !== \"none\" && el.style.visibility !== \"hidden\";\na11y.ts:81 } else if (!e.shiftKey && (active === last || active === container)) {\nCreateChannelModal.ts:163 createBtn.setAttribute(\"disabled\", \"true\");",
|
||||
"status": "fixed",
|
||||
"found": "2026-08-19",
|
||||
"hunt": "2026-08-19-general",
|
||||
"lens": "explore-3",
|
||||
"finder": "opus",
|
||||
"confidence": "high",
|
||||
"fix": {
|
||||
"commit": "a84e2f5a",
|
||||
"test": "Client/tauri-client/tests/unit/a11y.test.ts",
|
||||
"revertProof": "pass"
|
||||
},
|
||||
"suggestedFix": "Exclude disabled controls in the one shared place: `const FOCUSABLE_SELECTOR = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"])';` (or add `!el.hasAttribute(\"disabled\")` to isFocusable, a11y.ts:26).",
|
||||
"fixedDate": "2026-08-19"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -119,6 +119,13 @@ fn set_with(
|
||||
fallback_set: impl FnOnce(&str, &str) -> Result<(), String>,
|
||||
fallback_clear: impl FnOnce(&str),
|
||||
) -> Result<Backend, String> {
|
||||
// Set only when the keyring write itself failed and a stale prior entry
|
||||
// needs to be purged — but not until the fallback write below has proven
|
||||
// it actually committed a replacement copy. Deleting eagerly here would,
|
||||
// if the fallback write also fails, destroy the only good copy of the
|
||||
// secret and leave nothing anywhere for it to hand off to.
|
||||
let mut purge_stale_keyring_after_fallback_commits = false;
|
||||
|
||||
match keyring_set(account, secret) {
|
||||
Ok(()) => match keyring_get(account) {
|
||||
// The normal path: written and read back byte-for-byte.
|
||||
@@ -160,17 +167,26 @@ fn set_with(
|
||||
// successful write. get() reads the keyring first, so leaving
|
||||
// that stale entry in place would shadow the fresh secret parked
|
||||
// in the fallback below — mirrors the read-back-mismatch arm
|
||||
// above, which purges for the same reason.
|
||||
if let Err(de) = keyring_delete(account) {
|
||||
log::warn!(
|
||||
"{SERVICE}: could not remove a stale keyring entry for '{account}' after a \
|
||||
failed write: {de}"
|
||||
);
|
||||
}
|
||||
// above, which purges for the same reason. But the purge must
|
||||
// wait until fallback_set below has actually committed the
|
||||
// replacement: deleting now, before that write is known to
|
||||
// succeed, risks erasing the last good copy of the secret if the
|
||||
// fallback write fails too.
|
||||
purge_stale_keyring_after_fallback_commits = true;
|
||||
}
|
||||
}
|
||||
|
||||
fallback_set(account, secret)?;
|
||||
|
||||
if purge_stale_keyring_after_fallback_commits {
|
||||
if let Err(de) = keyring_delete(account) {
|
||||
log::warn!(
|
||||
"{SERVICE}: could not remove a stale keyring entry for '{account}' after a \
|
||||
failed write: {de}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"{SERVICE}: account '{account}' is stored in the encrypted fallback file, not the OS \
|
||||
credential store. See docs/credential-storage.md"
|
||||
@@ -561,6 +577,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_with_keeps_the_stale_keyring_entry_when_the_write_and_fallback_both_fail() {
|
||||
// The bug: a failed keyring write must not delete the existing
|
||||
// keyring entry before the fallback write it is handing off to has
|
||||
// actually committed. If the fallback write also fails, deleting
|
||||
// first destroys the only good copy of the secret and the caller
|
||||
// (e.g. save_identity_key) gets an Err with nothing left anywhere —
|
||||
// the next get() then returns Ok(None), indistinguishable from
|
||||
// first login.
|
||||
use std::cell::Cell;
|
||||
let delete_called = Cell::new(false);
|
||||
let result = set_with(
|
||||
"acct",
|
||||
"new-secret",
|
||||
|_, _| Err("write failed".to_string()),
|
||||
|_| panic!("keyring_get must not run after a failed write"),
|
||||
|_| {
|
||||
delete_called.set(true);
|
||||
Ok(())
|
||||
},
|
||||
|_, _| Err("fallback failed too".to_string()),
|
||||
|_| {},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
!delete_called.get(),
|
||||
"a failed keyring write must not delete the existing entry until the fallback \
|
||||
write has actually committed a replacement copy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_with_returns_keyring_backend_when_the_write_round_trips() {
|
||||
use std::cell::Cell;
|
||||
|
||||
@@ -288,8 +288,24 @@ impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier {
|
||||
/// parsed on the TS side, which lowercases) — without folding case here, two
|
||||
/// callers with the same server in different case would pin/read different
|
||||
/// entries, opening a second, unpinned proxy tunnel.
|
||||
///
|
||||
/// Also strips brackets from a *portless* bracketed IPv6 literal ("[::1]" →
|
||||
/// "::1"), after the `:443` strip above runs (so "[::1]:443" also unwraps).
|
||||
/// The ws proxy computes this key from a bracketed `wss://[::1]/...`
|
||||
/// authority (ws.ts's `bracketBareIPv6Host` has to bracket a bare IPv6 host
|
||||
/// for the URL to parse at all — see OC-0163), while the http/livekit proxies
|
||||
/// may see the bare or default-port-bracketed form of the very same server —
|
||||
/// without unwrapping here those resolve to different keys and the same
|
||||
/// server's certificate gets pinned (and re-confirmed by the user) twice. A
|
||||
/// *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 {
|
||||
host.strip_suffix(":443").unwrap_or(host).to_ascii_lowercase()
|
||||
let stripped = host.strip_suffix(":443").unwrap_or(host);
|
||||
let unbracketed = stripped
|
||||
.strip_prefix('[')
|
||||
.and_then(|rest| rest.strip_suffix(']'))
|
||||
.unwrap_or(stripped);
|
||||
unbracketed.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Extract the host (with any non-default port) from a `wss://` URL.
|
||||
@@ -395,6 +411,26 @@ mod tests {
|
||||
assert_eq!(cert_store_key("example.com:8443"), "example.com:8443");
|
||||
}
|
||||
|
||||
// OC-0163: ws_connect (via extract_host on a bracketed "wss://[::1]/..."
|
||||
// URL, once ws.ts brackets a bare IPv6 host to make it parse) and
|
||||
// start_http_proxy/start_livekit_proxy (which see the bare or
|
||||
// livekit-bracketed form of the SAME server) must resolve to the SAME
|
||||
// pin, or the user is prompted to accept the first-use certificate twice
|
||||
// for one server. A bracketed literal with a non-default port keeps its
|
||||
// own distinct key, matching the un-bracketed "host:port" behavior above.
|
||||
#[test]
|
||||
fn cert_store_key_treats_bracketed_and_bare_ipv6_as_the_same_host() {
|
||||
assert_eq!(cert_store_key("[2001:db8::1]"), cert_store_key("2001:db8::1"));
|
||||
assert_eq!(cert_store_key("2001:db8::1"), "2001:db8::1");
|
||||
assert_eq!(cert_store_key("[2001:db8::1]"), "2001:db8::1");
|
||||
// The default-port livekit form ("[host]:443") also collapses to the
|
||||
// same key as the portless forms above.
|
||||
assert_eq!(cert_store_key("[2001:db8::1]:443"), "2001:db8::1");
|
||||
// A non-default port keeps the brackets — it is a genuinely distinct
|
||||
// key from the default-port host, same as the plain "host:port" case.
|
||||
assert_eq!(cert_store_key("[2001:db8::1]:8443"), "[2001:db8::1]:8443");
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -26,7 +26,7 @@ import { attachDragHandlers } from "./channel-sidebar/drag-reorder";
|
||||
import { rePinPeerIdentity } from "@lib/livekitSession";
|
||||
import { createIdentityMismatchModal } from "./CertMismatchModal";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import { membersStore, memberDisplayName } from "@stores/members.store";
|
||||
import { roleHasPermission, canManageChannels } from "@lib/permissions";
|
||||
import { Permission } from "@lib/types";
|
||||
import { importIdentityPublicKey, computeKeyFingerprint } from "@lib/e2eeCrypto";
|
||||
@@ -407,7 +407,14 @@ function renderVoiceChannelItem(
|
||||
avatar.style.background = pickAvatarColor(user.username);
|
||||
row.appendChild(avatar);
|
||||
|
||||
const nameEl = createElement("span", { class: "vu-name" }, user.username || "Unknown");
|
||||
// Render the same identity a rename shows everywhere else (member list,
|
||||
// message rows, DM sidebar) — memberDisplayName prefers the nickname,
|
||||
// falling back to the username. Security-sensitive surfaces (the E2EE
|
||||
// mismatch modal, the moderation menu below) intentionally keep
|
||||
// rendering user.username instead, since a nickname is user-settable.
|
||||
const member = membersStore.getState().members.get(user.userId);
|
||||
const label = (member !== undefined ? memberDisplayName(member) : user.username) || "Unknown";
|
||||
const nameEl = createElement("span", { class: "vu-name" }, label);
|
||||
row.appendChild(nameEl);
|
||||
|
||||
if (user.camera) {
|
||||
|
||||
@@ -239,15 +239,20 @@ function createMemberItem(
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
const isSelf = member.id === currentUserId;
|
||||
const onMessageUser = opts.onMessageUser;
|
||||
// `member` is the row's render-time snapshot; a presence-only update
|
||||
// (see patchPresence) recolors the dot in place without rebuilding the
|
||||
// row, so that snapshot's `status` can be stale. Re-resolve against the
|
||||
// live store so the popup always agrees with the dot it was opened from.
|
||||
const live = membersStore.getState().members.get(member.id) ?? member;
|
||||
activePopup = createUserProfilePopup({
|
||||
user: {
|
||||
id: member.id,
|
||||
username: member.username,
|
||||
avatar: member.avatar,
|
||||
role: member.role,
|
||||
status: member.status,
|
||||
displayName: member.displayName,
|
||||
customStatus: member.customStatus,
|
||||
id: live.id,
|
||||
username: live.username,
|
||||
avatar: live.avatar,
|
||||
role: live.role,
|
||||
status: live.status,
|
||||
displayName: live.displayName,
|
||||
customStatus: live.customStatus,
|
||||
},
|
||||
anchorX: e.clientX,
|
||||
anchorY: e.clientY,
|
||||
|
||||
@@ -389,12 +389,17 @@ export function createSettingsOverlay(
|
||||
},
|
||||
);
|
||||
|
||||
// Attach before syncing initial state: show() moves focus into the panel
|
||||
// via focusDialog(), and .focus() on a still-detached subtree is a
|
||||
// silent no-op. Callers that mount while settingsOpen is already true
|
||||
// (e.g. ConnectPage's lazy first-open path) would otherwise get a
|
||||
// visible overlay whose focus trap never actually captures focus.
|
||||
container.appendChild(root);
|
||||
|
||||
// Sync initial state
|
||||
if (uiStore.getState().settingsOpen) {
|
||||
show();
|
||||
}
|
||||
|
||||
container.appendChild(root);
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { createElement, appendChildren, clearChildren } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import { Disposable } from "@lib/disposable";
|
||||
import { membersStore, getTypingUsers } from "@stores/members.store";
|
||||
import { membersStore, getTypingUsers, memberDisplayName } from "@stores/members.store";
|
||||
import type { Member } from "@stores/members.store";
|
||||
|
||||
export interface TypingIndicatorOptions {
|
||||
@@ -16,11 +16,13 @@ export interface TypingIndicatorOptions {
|
||||
}
|
||||
|
||||
function formatTypingText(users: readonly Member[]): string {
|
||||
const name0 = users[0] ? memberDisplayName(users[0]) : undefined;
|
||||
const name1 = users[1] ? memberDisplayName(users[1]) : undefined;
|
||||
if (users.length === 1) {
|
||||
return `${users[0]?.username ?? "Someone"} is typing...`;
|
||||
return `${name0 ?? "Someone"} is typing...`;
|
||||
}
|
||||
if (users.length === 2) {
|
||||
return `${users[0]?.username ?? "Someone"} and ${users[1]?.username ?? "Someone"} are typing...`;
|
||||
return `${name0 ?? "Someone"} and ${name1 ?? "Someone"} are typing...`;
|
||||
}
|
||||
return "Several people are typing...";
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ function urlEnd(src: string, i: number): number {
|
||||
end -= run[0].length;
|
||||
continue;
|
||||
}
|
||||
if (end > min && src[end - 1] === "*") {
|
||||
if (end > min && (src[end - 1] === "*" || src[end - 1] === "_")) {
|
||||
end--;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export function renderReactions(
|
||||
class: reaction.me ? "reaction-chip me" : "reaction-chip",
|
||||
// Focusable so the who-reacted tooltip is reachable without a pointer.
|
||||
tabindex: "0",
|
||||
role: "button",
|
||||
"data-emoji": reaction.emoji,
|
||||
});
|
||||
// Reaction strings are free-form, so a custom reaction is stored as the
|
||||
@@ -33,6 +34,7 @@ export function renderReactions(
|
||||
chip.appendChild(emoji);
|
||||
chip.appendChild(count);
|
||||
chip.addEventListener("click", () => opts.onReactionClick(msg.id, reaction.emoji), { signal });
|
||||
addKeyActivation(chip, () => opts.onReactionClick(msg.id, reaction.emoji), signal);
|
||||
attachReactionTooltip(
|
||||
chip,
|
||||
{
|
||||
@@ -45,8 +47,33 @@ export function renderReactions(
|
||||
);
|
||||
container.appendChild(chip);
|
||||
}
|
||||
const addBtn = createElement("span", { class: "reaction-chip add-reaction" }, "+");
|
||||
const addBtn = createElement(
|
||||
"span",
|
||||
{ class: "reaction-chip add-reaction", tabindex: "0", role: "button" },
|
||||
"+",
|
||||
);
|
||||
addBtn.addEventListener("click", () => opts.onReactionClick(msg.id, ""), { signal });
|
||||
addKeyActivation(addBtn, () => opts.onReactionClick(msg.id, ""), signal);
|
||||
container.appendChild(addBtn);
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare <span role="button"> gets no native key activation, unlike a real
|
||||
* <button>. Mirror Enter/Space onto the same handler the click listener
|
||||
* uses, so a chip is actually usable from the keyboard once it is reachable
|
||||
* (mirrors QuickSwitchOverlay.ts's item/keydown pattern).
|
||||
*/
|
||||
function addKeyActivation(el: Element, onActivate: () => void, signal: AbortSignal): void {
|
||||
el.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
const key = (e as KeyboardEvent).key;
|
||||
if (key === "Enter" || key === " ") {
|
||||
e.preventDefault();
|
||||
onActivate();
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1148,7 +1148,18 @@ export function buildAccountTab(
|
||||
void options
|
||||
.onUpdateProfile({ username: newName })
|
||||
.then(() => {
|
||||
setText(headerName, newName);
|
||||
// The header shows the resolved display name, not the raw
|
||||
// username — mirror buildProfileFields' onSaved callback so the
|
||||
// two writers of `.account-header-name` agree (OC-0188). The
|
||||
// store is already updated by the time this resolves, so read it
|
||||
// fresh rather than assuming the username *is* the display name.
|
||||
setText(
|
||||
headerName,
|
||||
resolveDisplayName({
|
||||
username: newName,
|
||||
displayName: authStore.getState().user?.display_name ?? null,
|
||||
}),
|
||||
);
|
||||
setText(usernameValue, newName);
|
||||
editForm.style.display = "none";
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
/** The elements a dialog's Tab cycle visits. */
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
/**
|
||||
* Elements the app hides via inline `style.display = "none"` (the codebase's
|
||||
@@ -21,6 +21,11 @@ const FOCUSABLE_SELECTOR =
|
||||
* or "last" focusable leaves .focus() a no-op and the Tab trap comparing
|
||||
* against an edge focus never actually reached — Tab then falls through to
|
||||
* the browser's native order and can walk out of the dialog entirely.
|
||||
*
|
||||
* A `disabled` control has the identical failure mode: it can never be
|
||||
* `document.activeElement` (disabling a focused control blurs it to
|
||||
* `document.body`, outside the container the trap listens on), so it is
|
||||
* excluded at the selector level above rather than here.
|
||||
*/
|
||||
function isFocusable(el: HTMLElement): boolean {
|
||||
return el.style.display !== "none" && el.style.visibility !== "hidden";
|
||||
|
||||
@@ -15,9 +15,17 @@
|
||||
* the one who chose the certificate.
|
||||
*/
|
||||
|
||||
import { bracketBareIPv6Host } from "./ws";
|
||||
|
||||
/** The admin-panel URL for `host`, deep-linked to `section` when given. */
|
||||
export function adminPanelUrl(host: string, section?: string): string {
|
||||
const base = `https://${host}/admin`;
|
||||
// A bare (unbracketed) IPv6 host is a valid, accepted server address
|
||||
// (hostValidation.ts, livekitSession.ts's ensureLiveKitProxy) but RFC 3986
|
||||
// requires brackets around an IPv6 literal authority — without them this
|
||||
// isn't a valid absolute URL at all (OC-0190). Reuse the same bracketing
|
||||
// convention ws.ts's wss:// URL builder already uses.
|
||||
const authority = bracketBareIPv6Host(host);
|
||||
const base = `https://${authority}/admin`;
|
||||
return section === undefined || section === "" ? base : `${base}#${section}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { createLogger } from "./logger";
|
||||
import { ensureHttpProxy } from "./httpProxy";
|
||||
import { isValidHost } from "./hostValidation";
|
||||
import type {
|
||||
AuthResponse,
|
||||
RegisterResponse,
|
||||
@@ -77,22 +78,6 @@ const log = createLogger("api");
|
||||
|
||||
/** Create the REST API client. */
|
||||
export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: OnUnauthorized) {
|
||||
// oxlint-disable-next-line consistent-function-scoping -- co-located with createApiClient for encapsulation
|
||||
function isValidHost(host: string): boolean {
|
||||
if (host.length > 253) return false;
|
||||
// Bracketed IPv6 literal ("[::1]" or "[::1]:8443") — same convention as
|
||||
// livekitSession.ts's ensureLiveKitProxy and http_proxy.rs /
|
||||
// livekit_proxy.rs's validate_remote_host + parse_server_name.
|
||||
if (/^\[[0-9A-Fa-f:.]+\](:\d+)?$/.test(host)) return true;
|
||||
// Bare (unbracketed) IPv6 literal, e.g. "2001:db8::1" or "::1". More than
|
||||
// one colon means the whole string is the address — a single colon is
|
||||
// reserved for the host:port separator below, matching how
|
||||
// ensureLiveKitProxy tells "[::1]:port" apart from "host:port".
|
||||
if ((host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host)) return true;
|
||||
// DNS name or IPv4 literal, optionally with a port.
|
||||
return /^[\w.-]+(:\d+)?$/.test(host);
|
||||
}
|
||||
|
||||
let config = { ...initialConfig };
|
||||
|
||||
// REST traffic is tunneled through the Rust HTTP TOFU proxy: instead of
|
||||
@@ -124,6 +109,7 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
path: string,
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
opts?: { skipUnauthorized?: boolean },
|
||||
): Promise<T> {
|
||||
const url = `${urlBase}${path}`;
|
||||
const init: RequestInit = {
|
||||
@@ -153,7 +139,17 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
log.debug(`${label} ←`, { method, path, status: res.status });
|
||||
|
||||
if (res.status === 401) {
|
||||
onUnauthorized?.();
|
||||
// Most 401s mean "the session is no longer valid" — the global sink
|
||||
// (onUnauthorized) reacts by logging the user out and, for a
|
||||
// remembered host, deleting the saved credential. A handful of
|
||||
// endpoints instead use 401 as an ordinary per-call verdict (e.g.
|
||||
// "invalid two-factor code" on totp/confirm) while the caller's
|
||||
// session stays perfectly valid; those callers opt out via
|
||||
// `skipUnauthorized` so a wrong answer there doesn't sign the user
|
||||
// out and erase their stored credential.
|
||||
if (!opts?.skipUnauthorized) {
|
||||
onUnauthorized?.();
|
||||
}
|
||||
const err = await parseError(res);
|
||||
throw new ApiClientError(401, err.error, err.message);
|
||||
}
|
||||
@@ -186,8 +182,9 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
path: string,
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
opts?: { skipUnauthorized?: boolean },
|
||||
): Promise<T> {
|
||||
return doFetch<T>("API", await baseUrl(), method, path, body, signal);
|
||||
return doFetch<T>("API", await baseUrl(), method, path, body, signal, opts);
|
||||
}
|
||||
|
||||
async function adminRequest<T>(
|
||||
@@ -393,7 +390,15 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
|
||||
},
|
||||
|
||||
confirmTotp(password: string, code: string, signal?: AbortSignal): Promise<void> {
|
||||
return request<void>("POST", "/users/me/totp/confirm", { password, code }, signal);
|
||||
// Unlike every other endpoint on this client, a wrong answer here
|
||||
// (an invalid enrollment code) is reported as 401 UNAUTHORIZED rather
|
||||
// than 400/403 — see doFetch's `skipUnauthorized`. Without this the
|
||||
// global session-expiry sink would fire on a mistyped code, signing
|
||||
// the user out and deleting their stored credential for a session
|
||||
// that was never actually invalid.
|
||||
return request<void>("POST", "/users/me/totp/confirm", { password, code }, signal, {
|
||||
skipUnauthorized: true,
|
||||
});
|
||||
},
|
||||
|
||||
disableTotp(password: string, signal?: AbortSignal): Promise<void> {
|
||||
|
||||
@@ -68,6 +68,7 @@ import type { DmChannel } from "@stores/dm.store";
|
||||
import { setBlockedByMe, setUserBlockedByThem, clearBlockedByThem } from "@stores/blocks.store";
|
||||
import { setCustomEmoji } from "@stores/emoji.store";
|
||||
import type { DmChannelPayload } from "./types";
|
||||
import { isTextLikeChannel } from "./types";
|
||||
import type { ApiClient } from "./api";
|
||||
import { invalidateReactionUsers } from "@components/message-list/reaction-tooltip";
|
||||
import { notifyIncomingMessage } from "./notifications";
|
||||
@@ -335,7 +336,7 @@ export function wireDispatcher(
|
||||
// must NOT mark-read whatever the auto-select branch just picked.
|
||||
let activeChannelCleared = false;
|
||||
if (currentActive === null && payload.channels.length > 0) {
|
||||
const firstText = payload.channels.find((ch) => ch.type === "text");
|
||||
const firstText = payload.channels.find((ch) => isTextLikeChannel(ch));
|
||||
if (firstText !== undefined) {
|
||||
setActiveChannel(firstText.id);
|
||||
}
|
||||
@@ -540,7 +541,7 @@ export function wireDispatcher(
|
||||
return;
|
||||
}
|
||||
const firstText = [...channelsStore.getState().channels.values()]
|
||||
.filter((ch) => ch.type === "text")
|
||||
.filter((ch) => isTextLikeChannel(ch))
|
||||
.toSorted((a, b) => a.position - b.position)[0];
|
||||
setActiveChannel(firstText?.id ?? null);
|
||||
});
|
||||
@@ -713,7 +714,7 @@ export function wireDispatcher(
|
||||
if (payload.id === activeId) {
|
||||
const remaining = channelsStore.select((s) => s.channels);
|
||||
const sorted = [...remaining.values()]
|
||||
.filter((ch) => ch.type === "text")
|
||||
.filter((ch) => isTextLikeChannel(ch))
|
||||
.toSorted((a, b) => a.position - b.position);
|
||||
const firstTextId = sorted.length > 0 ? sorted[0]!.id : null;
|
||||
setActiveChannel(firstTextId);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Shared host/host:port validator for server addresses entered anywhere in
|
||||
// the client (the Add Server modal, the login form's implicit acceptance via
|
||||
// api.ts's setConfig, and anywhere else that needs to gate a user-supplied
|
||||
// address before it reaches the Rust HTTP/LiveKit proxies).
|
||||
//
|
||||
// Historically this lived as a private closure inside createApiClient
|
||||
// (api.ts) and ServerPanel's Add Server modal carried its own, narrower
|
||||
// regex that never grew IPv6 support when api.ts's did (OC-0187) — an IPv6
|
||||
// server could be logged into via the login form but never saved as a
|
||||
// profile. Keeping one implementation here means every caller accepts (and
|
||||
// rejects) the same set of addresses.
|
||||
|
||||
/**
|
||||
* True if `host` is an acceptable server address: a DNS name or IPv4
|
||||
* literal, optionally with a port, a bracketed IPv6 literal ("[::1]" or
|
||||
* "[::1]:8443"), or a bare (unbracketed) IPv6 literal ("2001:db8::1", "::1").
|
||||
*
|
||||
* Mirrors the Rust proxies' `validate_remote_host` / `parse_server_name`
|
||||
* (http_proxy.rs / livekit_proxy.rs) and livekitSession.ts's
|
||||
* ensureLiveKitProxy: same bracket convention, same "more than one colon
|
||||
* means the whole string is an IPv6 address" rule for telling a bare IPv6
|
||||
* literal apart from a single "host:port" separator.
|
||||
*/
|
||||
export function isValidHost(host: string): boolean {
|
||||
if (host.length > 253) return false;
|
||||
// Bracketed IPv6 literal ("[::1]" or "[::1]:8443").
|
||||
if (/^\[[0-9A-Fa-f:.]+\](:\d+)?$/.test(host)) return true;
|
||||
// Bare (unbracketed) IPv6 literal, e.g. "2001:db8::1" or "::1". More than
|
||||
// one colon means the whole string is the address — a single colon is
|
||||
// reserved for the host:port separator below.
|
||||
if ((host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host)) return true;
|
||||
// DNS name or IPv4 literal, optionally with a port.
|
||||
return /^[\w.-]+(:\d+)?$/.test(host);
|
||||
}
|
||||
@@ -804,10 +804,25 @@ export class E2EEManager {
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.deps.getWs()?.send({
|
||||
type: "voice_e2ee_offer",
|
||||
payload: { target_user_id: userId, encrypted_key: encryptedKey, iv },
|
||||
});
|
||||
// Routed through the shared sendOfferPaced budget (OC-0167) rather
|
||||
// than sent directly — setupKeyExchange's queued-announce drain can
|
||||
// call this once per existing participant in an uninterrupted loop
|
||||
// (a key holder joining a large ongoing call), and those sends must
|
||||
// draw from the same per-second budget as rotation offers instead of
|
||||
// bypassing pacing entirely.
|
||||
const sent = await this.sendOfferPaced(
|
||||
userId,
|
||||
encryptedKey,
|
||||
iv,
|
||||
() => this._e2eeEpoch !== epochBefore || this._ecdhKeyPair !== keypair,
|
||||
);
|
||||
if (!sent) {
|
||||
log.info(
|
||||
"E2EE: discarding stale announce-offer (epoch or keypair changed during pacing pause)",
|
||||
{ userId, epochBefore, epochNow: this._e2eeEpoch },
|
||||
);
|
||||
return;
|
||||
}
|
||||
log.info("E2EE: sent room key offer to peer", { userId });
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -948,11 +963,74 @@ export class E2EEManager {
|
||||
* Stay under it with margin rather than reading the limit back from the
|
||||
* server. */
|
||||
private static readonly OFFER_RATE_LIMIT_PER_SEC = 60;
|
||||
/** ponytail: fixed batch+sleep pacing, not a token bucket — the server
|
||||
* window is a flat per-second cap, so "send 60, wait a bit over a
|
||||
* second" is the whole algorithm needed. Upgrade if the cap ever becomes
|
||||
* variable or sub-second. */
|
||||
/** Sliding-window length. The server window is a flat per-second cap, so
|
||||
* "at most LIMIT sends inside any WINDOW_MS-wide slice" is the whole
|
||||
* algorithm needed. Upgrade if the cap ever becomes variable or
|
||||
* sub-second. */
|
||||
private static readonly OFFER_RATE_WINDOW_MS = 1_100;
|
||||
/** Timestamps (Date.now()) of every voice_e2ee_offer sent within the
|
||||
* current OFFER_RATE_WINDOW_MS window — an INSTANCE-level sliding-window
|
||||
* budget shared by every offer-send path (rotation, become-holder, its H3
|
||||
* late-arrival pass, AND announce-driven offers), never a per-call
|
||||
* counter. A per-call counter (the original OC-0005 fix) resets to zero
|
||||
* on every distributeRoomKey invocation, so two back-to-back rotations —
|
||||
* the second one run immediately by drainPendingRotationOrArmTimer —
|
||||
* each got their own fresh budget and together could blow through the
|
||||
* server's single per-second window (OC-0155); handleAnnounceInner's
|
||||
* drain-time offer send bypassed the budget altogether (OC-0167). Reset
|
||||
* in clearState(). */
|
||||
private _offerSendTimes: number[] = [];
|
||||
|
||||
/** Drop timestamps that have aged out of the current pacing window. */
|
||||
private pruneOfferSendTimes(): void {
|
||||
const cutoff = Date.now() - E2EEManager.OFFER_RATE_WINDOW_MS;
|
||||
while (this._offerSendTimes.length > 0 && (this._offerSendTimes[0] ?? Infinity) <= cutoff) {
|
||||
this._offerSendTimes.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one voice_e2ee_offer, pacing under the server's per-(sender,
|
||||
* channel) sliding-window rate limit (OC-0005/OC-0155/OC-0167;
|
||||
* Server/ws/voice_e2ee.go voiceE2EEOfferRateLimit=64/1s). Prunes this
|
||||
* instance's send timestamps older than OFFER_RATE_WINDOW_MS, and if
|
||||
* OFFER_RATE_LIMIT_PER_SEC sends already fall inside the window, waits for
|
||||
* the oldest of them to age out before sending — so every offer-send path
|
||||
* draws from ONE shared budget instead of each resetting its own.
|
||||
*
|
||||
* `isStale`, when given, is re-checked after any pacing wait (never
|
||||
* before) so a keypair/room-key/epoch swap that lands during the wait is
|
||||
* caught right before the send — the same protection distributeRoomKey and
|
||||
* handleAnnounceInner already apply around the wrap itself (findings v045,
|
||||
* v101). Returns false (and sends nothing) when `isStale` reports true
|
||||
* post-wait.
|
||||
*/
|
||||
private async sendOfferPaced(
|
||||
targetUserId: number,
|
||||
encryptedKey: string,
|
||||
iv: string,
|
||||
isStale?: () => boolean,
|
||||
): Promise<boolean> {
|
||||
this.pruneOfferSendTimes();
|
||||
if (this._offerSendTimes.length >= E2EEManager.OFFER_RATE_LIMIT_PER_SEC) {
|
||||
// Guarded by the length check above — the array is non-empty here.
|
||||
const oldest = this._offerSendTimes[0] as number;
|
||||
const waitMs = oldest + E2EEManager.OFFER_RATE_WINDOW_MS - Date.now();
|
||||
if (waitMs > 0) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
|
||||
}
|
||||
this.pruneOfferSendTimes();
|
||||
if (isStale?.()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this._offerSendTimes.push(Date.now());
|
||||
this.deps.getWs()?.send({
|
||||
type: "voice_e2ee_offer",
|
||||
payload: { target_user_id: targetUserId, encrypted_key: encryptedKey, iv },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the room key for each peer and send an offer, one at a time. Bails
|
||||
@@ -963,17 +1041,17 @@ export class E2EEManager {
|
||||
* next rotation (finding v045). Shared by the become-holder distribution,
|
||||
* its late-arrival (H3) pass, and the periodic rotation loop.
|
||||
*
|
||||
* Paces sends at OFFER_RATE_LIMIT_PER_SEC per OFFER_RATE_WINDOW_MS to stay
|
||||
* under the server's per-(sender,channel) rate limit (OC-0005) — without
|
||||
* this, a rotation in a large channel silently drops every offer past the
|
||||
* cap, and the same tail peers stay stranded on the old key forever.
|
||||
* Sends go through sendOfferPaced's shared instance-level budget to stay
|
||||
* under the server's per-(sender,channel) rate limit (OC-0005/OC-0155) —
|
||||
* without this, a rotation (or two back-to-back rotations) in a large
|
||||
* channel silently drops every offer past the cap, and the same tail peers
|
||||
* stay stranded on the old key forever.
|
||||
*/
|
||||
private async distributeRoomKey(
|
||||
keypair: CryptoKeyPair,
|
||||
roomKey: Uint8Array,
|
||||
peers: Iterable<[number, CryptoKey]>,
|
||||
): Promise<void> {
|
||||
let sentInWindow = 0;
|
||||
for (const [peerId, peerKey] of peers) {
|
||||
if (this._ecdhKeyPair !== keypair || this._roomKey !== roomKey) {
|
||||
log.warn("E2EE: aborting key distribution — keypair/room key changed mid-loop", {
|
||||
@@ -981,19 +1059,6 @@ export class E2EEManager {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (sentInWindow >= E2EEManager.OFFER_RATE_LIMIT_PER_SEC) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, E2EEManager.OFFER_RATE_WINDOW_MS));
|
||||
sentInWindow = 0;
|
||||
if (this._ecdhKeyPair !== keypair || this._roomKey !== roomKey) {
|
||||
log.info(
|
||||
"E2EE: aborting key distribution — keypair/room key changed during pacing pause",
|
||||
{
|
||||
peerId,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { encryptedKey, iv } = await wrapRoomKey(keypair.privateKey, peerKey, roomKey);
|
||||
if (this._ecdhKeyPair !== keypair || this._roomKey !== roomKey) {
|
||||
log.info("E2EE: discarding stale room-key offer (keypair/room key changed during wrap)", {
|
||||
@@ -1001,11 +1066,19 @@ export class E2EEManager {
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.deps.getWs()?.send({
|
||||
type: "voice_e2ee_offer",
|
||||
payload: { target_user_id: peerId, encrypted_key: encryptedKey, iv },
|
||||
});
|
||||
sentInWindow++;
|
||||
const sent = await this.sendOfferPaced(
|
||||
peerId,
|
||||
encryptedKey,
|
||||
iv,
|
||||
() => this._ecdhKeyPair !== keypair || this._roomKey !== roomKey,
|
||||
);
|
||||
if (!sent) {
|
||||
log.info(
|
||||
"E2EE: discarding stale room-key offer (keypair/room key changed during pacing pause)",
|
||||
{ peerId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1280,6 +1353,10 @@ export class E2EEManager {
|
||||
this._rotationPending = false;
|
||||
this._e2eeEpoch = 0;
|
||||
this._pendingAnnounces.length = 0;
|
||||
// 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.
|
||||
this._offerSendTimes.length = 0;
|
||||
this.clearKeyRotationTimer();
|
||||
this.clearReconnectConfirmTimer();
|
||||
// Reject (not resolve) so waiting setupKeyExchange sees a failure, not a
|
||||
|
||||
@@ -45,30 +45,42 @@ export interface PresenceSender {
|
||||
*/
|
||||
export function createPresenceSender(ws: WsClient, limiter: RateLimiter): PresenceSender {
|
||||
let retry: ReturnType<typeof setTimeout> | null = null;
|
||||
// The custom_status a still-queued retry carries. A plain status change
|
||||
// (customStatus === undefined) landing while that retry is pending does
|
||||
// not mean "clear the custom status" — it means the caller simply didn't
|
||||
// mention it — so send() below falls back to this instead of dropping it
|
||||
// (OC-0156).
|
||||
let pendingCustom: string | undefined;
|
||||
|
||||
function send(status: UserStatus, customStatus?: string): void {
|
||||
// A plain call inherits whatever custom_status is still queued behind
|
||||
// the limiter; an explicit call always wins outright.
|
||||
const effectiveCustom =
|
||||
customStatus !== undefined ? customStatus : retry !== null ? pendingCustom : undefined;
|
||||
const userId = authStore.getState().user?.id ?? 0;
|
||||
if (userId !== 0) {
|
||||
updatePresence(userId, status, customStatus);
|
||||
updatePresence(userId, status, effectiveCustom);
|
||||
}
|
||||
if (retry !== null) {
|
||||
clearTimeout(retry);
|
||||
retry = null;
|
||||
}
|
||||
if (limiter.tryConsume()) {
|
||||
if (customStatus === undefined) {
|
||||
pendingCustom = undefined;
|
||||
if (effectiveCustom === undefined) {
|
||||
ws.send({ type: "presence_update", payload: { status } });
|
||||
} else {
|
||||
ws.send({ type: "presence_update", payload: { status, custom_status: customStatus } });
|
||||
ws.send({ type: "presence_update", payload: { status, custom_status: effectiveCustom } });
|
||||
}
|
||||
} else {
|
||||
// The window is still closed from an earlier send (any producer's) —
|
||||
// retry once it reopens instead of dropping this one silently.
|
||||
// Re-reads loadUserStatus() at fire time so a burst of calls in
|
||||
// between coalesces onto a single retry carrying the latest value.
|
||||
pendingCustom = effectiveCustom;
|
||||
retry = setTimeout(() => {
|
||||
retry = null;
|
||||
send(loadUserStatus(), customStatus);
|
||||
send(loadUserStatus(), effectiveCustom);
|
||||
}, limiter.getRemainingMs());
|
||||
}
|
||||
}
|
||||
@@ -78,7 +90,33 @@ export function createPresenceSender(ws: WsClient, limiter: RateLimiter): Presen
|
||||
clearTimeout(retry);
|
||||
retry = null;
|
||||
}
|
||||
pendingCustom = undefined;
|
||||
}
|
||||
|
||||
return { send, destroy };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active-session registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let activeSender: PresenceSender | null = null;
|
||||
|
||||
/**
|
||||
* Register the session's one `PresenceSender` so producers that are wired up
|
||||
* before any session exists — main.ts's tray "status-change" listener, which
|
||||
* is registered at module load, long before a login — can still route
|
||||
* through the same shared limiter/retry/optimistic-update instead of
|
||||
* sending `presence_update` raw and opening a second budget the server does
|
||||
* not know about (OC-0176). MainPage.ts calls this right after constructing
|
||||
* its `PresenceSender`, and again with `null` in its teardown.
|
||||
*/
|
||||
export function setActivePresenceSender(sender: PresenceSender | null): void {
|
||||
activeSender = sender;
|
||||
}
|
||||
|
||||
/** The current session's `PresenceSender`, or `null` when no session is
|
||||
* mounted (before login, or after logout/disconnect). */
|
||||
export function getActivePresenceSender(): PresenceSender | null {
|
||||
return activeSender;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { loadPref, savePref } from "@components/settings/helpers";
|
||||
import { voiceStore, setPttGated, setPttPollingLive } from "@stores/voice.store";
|
||||
import { voiceStore, setPttGated, setPttPollingLive, isPttPollingLive } from "@stores/voice.store";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const log = createLogger("ptt");
|
||||
@@ -268,6 +268,23 @@ export async function updatePttKey(vk: number): Promise<void> {
|
||||
await invoke("ptt_set_key", { vkCode: vk });
|
||||
if (!listening && vk !== 0) {
|
||||
await initPtt();
|
||||
// Binding a key while a call is already up: the Rust poller only
|
||||
// emits 'ptt-state' on a press/release TRANSITION (ptt_transition
|
||||
// returns None while the key stays idle — see src-tauri/src/ptt.rs),
|
||||
// so an idle key produces no event to gate the freshly-armed mic.
|
||||
// Mirror livekitSession's join-time computation (restoreLocalVoiceState)
|
||||
// here so the mic doesn't stay hot until the user's first press+release.
|
||||
const { currentChannelId, pttGated, localMuted } = voiceStore.getState();
|
||||
if (currentChannelId !== null && isPttPollingLive() && pttGated !== true) {
|
||||
setPttGated(true);
|
||||
// Muting is always safe (mirrors the ptt-state release handler
|
||||
// below) — record whether this is what muted the mic so the next
|
||||
// press may lift it (v006: never lift a mute the user asked for).
|
||||
void import("./livekitSession")
|
||||
.then(({ setMuted }) => setMuted(true))
|
||||
.catch((e) => log.warn("Failed to gate mic after binding PTT key mid-call", e));
|
||||
pttOwnsMute = !localMuted;
|
||||
}
|
||||
}
|
||||
if (vk === 0) {
|
||||
await stopPtt();
|
||||
|
||||
@@ -21,6 +21,20 @@ export type UserStatus = "online" | "idle" | "dnd" | "invisible" | "offline";
|
||||
/** Channel types supported by the server. */
|
||||
export type ChannelType = "text" | "voice" | "announcement" | "dm";
|
||||
|
||||
/**
|
||||
* True for channel types that carry a message history and a chat pane —
|
||||
* "text" and "announcement". The server treats both identically for ready's
|
||||
* unread_count/last_message_id and can_send (Server/ws/serve_ready.go), and
|
||||
* the client renders both in the sidebar with a composer. Callers that pick
|
||||
* an automatic/fallback active channel from a channel list must use this
|
||||
* instead of a bare `type === "text"` check, or an announcement-only server
|
||||
* (or a role that can only read announcement channels) lands on a blank pane
|
||||
* even though a readable channel exists.
|
||||
*/
|
||||
export function isTextLikeChannel(ch: { readonly type: ChannelType }): boolean {
|
||||
return ch.type === "text" || ch.type === "announcement";
|
||||
}
|
||||
|
||||
/** Voice quality presets. */
|
||||
export type VoiceQuality = "low" | "medium" | "high";
|
||||
|
||||
|
||||
@@ -127,6 +127,27 @@ export function normalizeHostForCertCompare(host: string): string {
|
||||
return host.replace(/:443$/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a bare (unbracketed) IPv6 literal in brackets so it can be embedded in
|
||||
* a `wss://` authority, mirroring the detection api.ts's `isValidHost` and
|
||||
* livekitSession.ts's `ensureLiveKitProxy` already use: more than one colon
|
||||
* means the whole string is the address (a single colon is the host:port
|
||||
* separator instead), and RFC 3986 gives a bare IPv6 literal no way to carry
|
||||
* a port, so this never needs to split one off. A host that is already
|
||||
* bracketed (or is a DNS name / IPv4 literal, with or without a port) is
|
||||
* returned unchanged (OC-0163).
|
||||
*/
|
||||
export function bracketBareIPv6Host(host: string): string {
|
||||
if (
|
||||
!host.startsWith("[") &&
|
||||
(host.match(/:/g) ?? []).length > 1 &&
|
||||
/^[0-9A-Fa-f:.]+$/.test(host)
|
||||
) {
|
||||
return `[${host}]`;
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
export function createWsClient() {
|
||||
let config: WsClientConfig | null = null;
|
||||
let state: ConnectionState = "disconnected";
|
||||
@@ -238,11 +259,6 @@ export function createWsClient() {
|
||||
function handleMessage(raw: string): void {
|
||||
const maxSize = config?.maxMessageSizeBytes ?? DEFAULT_MAX_MESSAGE_SIZE;
|
||||
|
||||
if (raw.length > maxSize) {
|
||||
log.warn("Message exceeds size limit, dropping", { size: raw.length });
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: { type?: string; payload?: unknown; id?: string; seq?: number };
|
||||
try {
|
||||
parsed = JSON.parse(raw) as { type?: string; payload?: unknown; id?: string; seq?: number };
|
||||
@@ -253,6 +269,21 @@ export function createWsClient() {
|
||||
return;
|
||||
}
|
||||
|
||||
// The size guard runs AFTER parsing (raw is already fully materialized
|
||||
// in memory either way, so this costs nothing) and exempts the handshake
|
||||
// frames: "ready" is the one server frame with no bound — it embeds every
|
||||
// member/channel/DM the server knows about — and, unlike a sequenced
|
||||
// frame, carries no seq, so nothing ever re-requests it. Dropping it
|
||||
// (OC-0160) would leave a client that just flipped to "connected" sitting
|
||||
// on empty stores with no error and no recovery path. "auth_ok" gets the
|
||||
// same exemption since it can embed a long-username/motd payload and is
|
||||
// equally unrecoverable if dropped — the client never even reaches
|
||||
// "connected". Every other message type keeps the strict bound.
|
||||
if (raw.length > maxSize && parsed.type !== "ready" && parsed.type !== "auth_ok") {
|
||||
log.warn("Message exceeds size limit, dropping", { size: raw.length, type: parsed.type });
|
||||
return;
|
||||
}
|
||||
|
||||
// Track the highest sequence number for reconnection replay.
|
||||
const seq = typeof parsed.seq === "number" ? parsed.seq : 0;
|
||||
if (seq > lastSeq) {
|
||||
@@ -537,7 +568,7 @@ export function createWsClient() {
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = `wss://${cfg.host}/api/v1/ws`;
|
||||
const wsUrl = `wss://${bracketBareIPv6Host(cfg.host)}/api/v1/ws`;
|
||||
log.info("WebSocket connecting", {
|
||||
url: wsUrl,
|
||||
isReconnect: reconnectAttempt > 0,
|
||||
|
||||
@@ -37,6 +37,7 @@ import { reconnectAfterCertAccept } from "@lib/cert-reconnect";
|
||||
import { createProfileManager, createTauriBackend } from "@lib/profiles";
|
||||
import type { CertTofuEvent } from "@lib/ws";
|
||||
import { saveUserStatus } from "@lib/userStatus";
|
||||
import { getActivePresenceSender } from "@lib/presence";
|
||||
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
@@ -250,8 +251,15 @@ void ws.startCertListener();
|
||||
// tray's choice never reaches loadUserStatus(), so notifications.ts's DND
|
||||
// gate, autoIdle's "never touch a manual DND/invisible" guard, and
|
||||
// restoreSavedPresence() on the next reconnect all silently disagree with
|
||||
// what the tray just set (OC-0037). ws.send is a safe no-op (logged) when
|
||||
// there is no live session, so no auth guard is needed here.
|
||||
// what the tray just set (OC-0037). The send itself goes through the
|
||||
// session's shared PresenceSender (registered by MainPage.ts via
|
||||
// setActivePresenceSender) rather than a raw ws.send: the server enforces a
|
||||
// single 1-update/10s budget per user regardless of which surface sent the
|
||||
// frame, and a raw send here would open a second, uncoordinated budget that
|
||||
// silently drops whichever frame the server sees second (OC-0176). When no
|
||||
// session is mounted the optional call is a no-op, matching the old raw
|
||||
// ws.send's "safe no-op when disconnected" behavior, so no auth guard is
|
||||
// needed here.
|
||||
void listen<string>("status-change", (e) => {
|
||||
const status = e.payload;
|
||||
if (status === "online" || status === "idle" || status === "dnd" || status === "offline") {
|
||||
@@ -260,7 +268,7 @@ void listen<string>("status-change", (e) => {
|
||||
// doc comment) — the local pref and the wire message must agree.
|
||||
const mapped = status === "offline" ? "invisible" : status;
|
||||
saveUserStatus(mapped);
|
||||
ws.send({ type: "presence_update", payload: { status: mapped } });
|
||||
getActivePresenceSender()?.send(mapped);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -760,7 +768,18 @@ router.onNavigate((pageId) => {
|
||||
authStore.subscribeSelector(
|
||||
(s) => s.isAuthenticated,
|
||||
(isAuthenticated) => {
|
||||
if (!isAuthenticated && router.getCurrentPage() === "main") {
|
||||
// The router only reaches "main" from the connected overlay's own
|
||||
// onReady, 800ms after `ready` arrives — so a session that ends between
|
||||
// auth_ok and ready (a ban, an auth_error on an intervening reconnect,
|
||||
// a server_restart shutdown) flips isAuthenticated false while the
|
||||
// router is still "connect". Gate on connectedOverlay too so that case
|
||||
// still tears down: otherwise the overlay (position:fixed, opaque,
|
||||
// z-index 200, appended straight to #app in wirePostAuth's auth_ok
|
||||
// handler) is orphaned over the connect page with no remaining owner —
|
||||
// its only other teardown paths are its own onReady timer (never armed
|
||||
// without `ready`), the next wirePostAuth, onAutoLoginCancel, and the
|
||||
// invite deep-link handler, none of which this path takes (OC-0157).
|
||||
if (!isAuthenticated && (router.getCurrentPage() === "main" || connectedOverlay !== null)) {
|
||||
// Leave voice channel before disconnecting so other clients see it
|
||||
// immediately. Gated on clearAuth's logoutWasInVoice snapshot rather
|
||||
// than the live voiceStore: clearAuth applies state (including this
|
||||
@@ -777,6 +796,8 @@ authStore.subscribeSelector(
|
||||
dispatcherCleanup = null;
|
||||
sessionCleanup?.();
|
||||
sessionCleanup = null;
|
||||
connectedOverlay?.destroy();
|
||||
connectedOverlay = null;
|
||||
ws.disconnect();
|
||||
lastConnectToken = "";
|
||||
lastConnectHost = "";
|
||||
|
||||
@@ -20,7 +20,7 @@ import { logout } from "@lib/logout";
|
||||
import { authStore, clearAuth, updateUser } from "@stores/auth.store";
|
||||
import { closeSettings, uiStore } from "@stores/ui.store";
|
||||
import { loadUserStatus } from "@lib/userStatus";
|
||||
import { createPresenceSender } from "@lib/presence";
|
||||
import { createPresenceSender, setActivePresenceSender } from "@lib/presence";
|
||||
import { startAutoIdle, type AutoIdleController } from "@lib/autoIdle";
|
||||
import { channelsStore, getActiveChannel } from "@stores/channels.store";
|
||||
import { dmStore, dmDisplayName } from "@stores/dm.store";
|
||||
@@ -131,6 +131,12 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
// per producer instead cannot predict that shared, cross-surface budget
|
||||
// (OC-0210).
|
||||
const presenceSender = createPresenceSender(ws, limiters.presence);
|
||||
// Publish this as the session's one PresenceSender so producers wired up
|
||||
// outside MainPage — main.ts's tray "status-change" listener — share the
|
||||
// same limiter token, coalescing retry, and optimistic update instead of
|
||||
// opening a second budget the server doesn't know about (OC-0176). Cleared
|
||||
// in this page's teardown, alongside presenceSender.destroy() below.
|
||||
setActivePresenceSender(presenceSender);
|
||||
|
||||
let container: Element | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
@@ -360,7 +366,17 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
// the dispatcher) — no point starting a countdown on a page that is
|
||||
// about to unmount.
|
||||
if (banner !== null && payload.reason !== "shutdown") {
|
||||
banner.showRestart(payload.delay_seconds);
|
||||
if (payload.delay_seconds <= 0) {
|
||||
// A zero/negative delay is a cancel, not a countdown (e.g.
|
||||
// "update_aborted" correcting an earlier restart announcement
|
||||
// after the staged update failed to apply — the socket never
|
||||
// actually dropped). Re-sync to the real connection status
|
||||
// instead of letting showRestart's countdown fall straight
|
||||
// through to a permanent "Reconnecting..." banner.
|
||||
applyConnectionStatus(banner, uiStore.getState().connectionStatus);
|
||||
} else {
|
||||
banner.showRestart(payload.delay_seconds);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("Server restart handler error", err);
|
||||
@@ -802,6 +818,7 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
|
||||
autoIdle?.destroy();
|
||||
autoIdle = null;
|
||||
presenceSender.destroy();
|
||||
setActivePresenceSender(null);
|
||||
channelCtrl?.destroyChannel();
|
||||
channelCtrl = null;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createElement, setText, appendChildren, clearChildren } from "@lib/dom"
|
||||
import { createIcon } from "@lib/icons";
|
||||
import type { HealthStatus, ServerProfile } from "@lib/profiles";
|
||||
import { loadCredential } from "@lib/credentials";
|
||||
import { isValidHost } from "@lib/hostValidation";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -306,8 +307,11 @@ export function createServerPanel(
|
||||
const name = nameInput.value.trim();
|
||||
const addr = hostAddrInput.value.trim();
|
||||
if (!name || !addr) return;
|
||||
// Validate address: must be a valid hostname:port — no paths, no special chars
|
||||
if (!/^[\w.-]+(:\d+)?$/.test(addr)) {
|
||||
// Validate address: must be a valid host or host:port (DNS name, IPv4,
|
||||
// bracketed/bare IPv6) — no paths, no special chars. Shared with
|
||||
// api.ts's setConfig so an address accepted here is also accepted by
|
||||
// the actual connection path, and vice versa (OC-0187).
|
||||
if (!isValidHost(addr)) {
|
||||
// Show inline validation error via the host input
|
||||
hostAddrInput.setCustomValidity("Invalid server address (expected host or host:port)");
|
||||
hostAddrInput.reportValidity();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { createElement, setText, clearChildren } from "@lib/dom";
|
||||
import { isTextLikeChannel } from "@lib/types";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { WsClient } from "@lib/ws";
|
||||
import type { ApiClient } from "@lib/api";
|
||||
@@ -471,7 +472,7 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
return;
|
||||
}
|
||||
for (const ch of channelsStore.getState().channels.values()) {
|
||||
if (ch.type === "text") {
|
||||
if (isTextLikeChannel(ch)) {
|
||||
setActiveChannel(ch.id);
|
||||
break;
|
||||
}
|
||||
@@ -562,7 +563,7 @@ export function createSidebarArea(opts: SidebarAreaOptions): SidebarAreaResult {
|
||||
st.activeChannelId !== null ? st.channels.get(st.activeChannelId) : undefined;
|
||||
if (current !== undefined && current.type !== "dm") return;
|
||||
for (const ch of channelsStore.getState().channels.values()) {
|
||||
if (ch.type === "text") {
|
||||
if (isTextLikeChannel(ch)) {
|
||||
setActiveChannel(ch.id);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,20 @@ export function createSidebarDmSection(opts: SidebarDmSectionOptions): SidebarDm
|
||||
});
|
||||
const name = createElement("span", { class: "ch-name" }, dmDisplayName(dm));
|
||||
const parts: Element[] = [statusDot, name];
|
||||
if (dm.unreadCount > 0) {
|
||||
// A mention badge outranks the plain unread badge, and a mute never
|
||||
// dims or suppresses it: a mute silences chatter, never something
|
||||
// addressed to the reader directly (see lib/channel-mutes.ts).
|
||||
if (dm.mentionCount > 0) {
|
||||
const mentionBadge = createElement(
|
||||
"span",
|
||||
{
|
||||
class: "dm-mention-badge",
|
||||
style: `margin-left:auto;background:var(--red);color:white;border-radius:10px;padding:1px 6px;font-size:0.7rem;`,
|
||||
},
|
||||
String(dm.mentionCount),
|
||||
);
|
||||
parts.push(mentionBadge);
|
||||
} else if (dm.unreadCount > 0) {
|
||||
// Muted: the count still increments (it is a fact about the channel),
|
||||
// it just stops shouting. Only the colour changes.
|
||||
const badge = createElement(
|
||||
@@ -124,9 +137,11 @@ export function createSidebarDmSection(opts: SidebarDmSectionOptions): SidebarDm
|
||||
|
||||
// Update total unread badge on the DM header. Muted conversations are
|
||||
// excluded: the header badge is an interrupt, and a muted DM asked not to
|
||||
// be one. Its own row still shows its dimmed count.
|
||||
// be one. Its own row still shows its dimmed count. A mention is the one
|
||||
// thing a mute must never swallow, so a muted channel still contributes
|
||||
// its mentionCount (never its raw unreadCount) to the total.
|
||||
const totalUnread = dmChannels.reduce(
|
||||
(sum, c) => sum + (isChannelMuted(c.channelId) ? 0 : c.unreadCount),
|
||||
(sum, c) => sum + (isChannelMuted(c.channelId) ? c.mentionCount : c.unreadCount),
|
||||
0,
|
||||
);
|
||||
if (totalUnread > 0) {
|
||||
|
||||
@@ -93,8 +93,13 @@ export function createVoiceWidgetCallbacks(
|
||||
if (state.localDeafened) {
|
||||
voiceSessionSetDeafened(false);
|
||||
ws.send({ type: "voice_deafen", payload: { deafened: false } });
|
||||
voiceSessionSetMuted(false);
|
||||
ws.send({ type: "voice_mute", payload: { muted: false } });
|
||||
// A moderator-imposed mute is not ours to lift; the server refuses
|
||||
// the unmute, so don't spend the round-trip (same guard as
|
||||
// onMuteToggle above).
|
||||
if (state.localServerMuted !== true) {
|
||||
voiceSessionSetMuted(false);
|
||||
ws.send({ type: "voice_mute", payload: { muted: false } });
|
||||
}
|
||||
} else {
|
||||
voiceSessionSetDeafened(true);
|
||||
ws.send({ type: "voice_deafen", payload: { deafened: true } });
|
||||
|
||||
@@ -254,7 +254,19 @@ export function addMessage(payload: ChatMessagePayload): void {
|
||||
// below the gap and must wait for "Jump to Present".
|
||||
if (prev.detachedChannels.has(channelId)) return prev;
|
||||
|
||||
let updatedMsgs = [...existing, message];
|
||||
// Insert before any trailing unreconciled optimistic row(s) rather than
|
||||
// blindly appending at the tail. An optimistic row (status !== "sent")
|
||||
// has no real server id/timestamp yet — confirmSend will stamp it in
|
||||
// place once its ack arrives — so a message that commits and broadcasts
|
||||
// *while our own send is still in flight* must land ahead of it, or the
|
||||
// eventually-stamped row (a later server id/timestamp) ends up rendered
|
||||
// above an older message it should follow. Rows before the trailing
|
||||
// unreconciled run are already "sent" and keep their position.
|
||||
let insertAt = existing.length;
|
||||
while (insertAt > 0 && existing[insertAt - 1]!.status !== "sent") {
|
||||
insertAt--;
|
||||
}
|
||||
let updatedMsgs = [...existing.slice(0, insertAt), message, ...existing.slice(insertAt)];
|
||||
// Evict oldest messages if over the cap
|
||||
if (updatedMsgs.length > MAX_MESSAGES_PER_CHANNEL) {
|
||||
updatedMsgs = updatedMsgs.slice(updatedMsgs.length - MAX_MESSAGES_PER_CHANNEL);
|
||||
|
||||
@@ -2163,7 +2163,8 @@ ul.md-list-nested {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
z-index: 5;
|
||||
}
|
||||
.message:hover .msg-actions-bar {
|
||||
.message:hover .msg-actions-bar,
|
||||
.message:focus-within .msg-actions-bar {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
animation: actionBarPop 0.1s ease;
|
||||
|
||||
@@ -88,12 +88,16 @@ test.describe("Voice Widget", () => {
|
||||
const usersBefore = await page.locator(".voice-user-item").count();
|
||||
|
||||
// Another user joins the voice channel (id 4 — NOT already in
|
||||
// MOCK_VOICE_STATE, so this is a genuine join, not an in-place update)
|
||||
// MOCK_VOICE_STATE, so this is a genuine join, not an in-place update).
|
||||
// The username must match id 4's record in MOCK_MEMBERS_MULTI_ROLE
|
||||
// ("member2"): the roster resolves identity through membersStore so a
|
||||
// nickname shows the same everywhere, and a real server never sends a
|
||||
// voice_state whose username disagrees with the member of that id.
|
||||
await emitWsMessage(page, {
|
||||
type: "voice_state",
|
||||
payload: {
|
||||
user_id: 4,
|
||||
username: "newvoiceuser",
|
||||
username: "member2",
|
||||
channel_id: 10,
|
||||
muted: false,
|
||||
deafened: false,
|
||||
@@ -104,7 +108,7 @@ test.describe("Voice Widget", () => {
|
||||
});
|
||||
|
||||
// New user should appear in the sidebar voice-users-list (not the widget)
|
||||
const newUser = page.locator(".voice-user-item .vu-name", { hasText: "newvoiceuser" });
|
||||
const newUser = page.locator(".voice-user-item .vu-name", { hasText: "member2" });
|
||||
await expect(newUser).toBeVisible({ timeout: 5_000 });
|
||||
const usersAfter = await page.locator(".voice-user-item").count();
|
||||
expect(usersAfter).toBeGreaterThan(usersBefore);
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// jsdom ships no type declarations and @types/jsdom is not a dependency of
|
||||
// this project. Declare the surface the admin-panel test actually uses,
|
||||
// following the same pattern as src/types/jitsi-rnnoise.d.ts.
|
||||
declare module "jsdom" {
|
||||
export interface JSDOMOptions {
|
||||
url?: string;
|
||||
runScripts?: "dangerously" | "outside-only";
|
||||
pretendToBeVisual?: boolean;
|
||||
beforeParse?: (window: Window & typeof globalThis) => void;
|
||||
}
|
||||
|
||||
export class JSDOM {
|
||||
constructor(html: string, options?: JSDOMOptions);
|
||||
readonly window: Window & typeof globalThis & { close(): void };
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,33 @@ describe("trapFocus", () => {
|
||||
expect(document.activeElement).toBe(visible);
|
||||
ac.abort();
|
||||
});
|
||||
|
||||
it("skips a disabled trailing control when wrapping — Tab from the last enabled control wraps instead of escaping", () => {
|
||||
// Mirrors CreateChannelModal: the submit button is disabled while a
|
||||
// request is in flight (and, being disabled, gets blurred by the
|
||||
// browser first). The trap must treat the last *enabled* control as the
|
||||
// wrap edge, not the disabled one that can never hold focus.
|
||||
const ac = new AbortController();
|
||||
const dialog = document.createElement("div");
|
||||
applyDialogSemantics(dialog);
|
||||
const first = document.createElement("button");
|
||||
first.textContent = "first";
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.textContent = "cancel";
|
||||
const submitBtn = document.createElement("button");
|
||||
submitBtn.textContent = "submit";
|
||||
submitBtn.disabled = true;
|
||||
dialog.append(first, cancelBtn, submitBtn);
|
||||
container.appendChild(dialog);
|
||||
trapFocus(dialog, ac.signal);
|
||||
|
||||
cancelBtn.focus();
|
||||
const forward = tab(cancelBtn);
|
||||
|
||||
expect(forward.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(first);
|
||||
ac.abort();
|
||||
});
|
||||
});
|
||||
|
||||
describe("focusDialog", () => {
|
||||
@@ -229,4 +256,18 @@ describe("focusDialog", () => {
|
||||
|
||||
expect(document.activeElement).toBe(visible);
|
||||
});
|
||||
|
||||
it("skips a disabled control that is earlier in DOM order than the first enabled one", () => {
|
||||
const dialog = document.createElement("div");
|
||||
applyDialogSemantics(dialog);
|
||||
const disabledBtn = document.createElement("button");
|
||||
disabledBtn.disabled = true;
|
||||
const enabledBtn = document.createElement("button");
|
||||
dialog.append(disabledBtn, enabledBtn);
|
||||
container.appendChild(dialog);
|
||||
|
||||
focusDialog(dialog);
|
||||
|
||||
expect(document.activeElement).toBe(enabledBtn);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,22 @@ describe("adminPanelUrl", () => {
|
||||
it("does not route through the local http proxy", () => {
|
||||
expect(adminPanelUrl("localhost:8443")).not.toContain("127.0.0.1");
|
||||
});
|
||||
|
||||
// A bare IPv6 host is a valid, accepted server address (hostValidation.ts,
|
||||
// livekitSession.ts's ensureLiveKitProxy, ws.ts's bracketBareIPv6Host) but
|
||||
// RFC 3986 requires brackets around an IPv6 literal authority — without
|
||||
// them the string isn't a valid absolute URL at all (OC-0190).
|
||||
it("brackets a bare IPv6 host", () => {
|
||||
expect(adminPanelUrl("::1")).toBe("https://[::1]/admin");
|
||||
});
|
||||
|
||||
it("brackets a bare IPv6 host with a deep-linked section", () => {
|
||||
expect(adminPanelUrl("2001:db8::1", "audit")).toBe("https://[2001:db8::1]/admin#audit");
|
||||
});
|
||||
|
||||
it("leaves an already-bracketed IPv6 host unchanged", () => {
|
||||
expect(adminPanelUrl("[::1]:8443")).toBe("https://[::1]:8443/admin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openAdminPanel", () => {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// Loads the real Server/admin/static/index.html (the Go admin panel's
|
||||
// single-file SPA) into a scripted jsdom window and drives its inline
|
||||
// channel-permissions logic directly, the same way a browser would.
|
||||
//
|
||||
// There is no bundler or module system for this file — it is one inline
|
||||
// <script> executed as a classic script — so the only faithful way to test
|
||||
// it is to actually run it, not to re-implement its logic in TypeScript.
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ADMIN_HTML_PATH = path.resolve(__dirname, "../../../../Server/admin/static/index.html");
|
||||
const ADMIN_HTML_SOURCE = readFileSync(ADMIN_HTML_PATH, "utf8");
|
||||
|
||||
// The page's own <script> is a classic (non-module) script, so its top-level
|
||||
// `const`/`function` declarations live in the window's shared global script
|
||||
// scope but are never copied onto the `window` object itself — `window.state`
|
||||
// is undefined even though a later <script> in the same document can still
|
||||
// read `state` by name. Append a second script that bridges the handful of
|
||||
// bindings this test needs onto an explicit, test-only global.
|
||||
const BRIDGE = `<script>
|
||||
window.__test = {
|
||||
state: state,
|
||||
renderChannelPermsModal: renderChannelPermsModal,
|
||||
renderPermMatrix: renderPermMatrix,
|
||||
saveChannelPerms: saveChannelPerms
|
||||
};
|
||||
</script>`;
|
||||
if (!ADMIN_HTML_SOURCE.includes("</body>")) {
|
||||
throw new Error("expected Server/admin/static/index.html to contain </body>");
|
||||
}
|
||||
const ADMIN_HTML = ADMIN_HTML_SOURCE.replace("</body>", `${BRIDGE}\n</body>`);
|
||||
|
||||
interface FetchCall {
|
||||
method: string;
|
||||
path: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
function loadAdminPanel(fetchCalls: FetchCall[]): JSDOM {
|
||||
return new JSDOM(ADMIN_HTML, {
|
||||
url: "http://localhost:8080/admin",
|
||||
runScripts: "dangerously",
|
||||
pretendToBeVisual: true,
|
||||
beforeParse(window) {
|
||||
// Stand in for the real REST API. `api()` in the page prefixes every
|
||||
// path with /admin/api and JSON-encodes the body.
|
||||
window.fetch = (async (input: string, opts: Record<string, unknown> = {}) => {
|
||||
const method = String((opts.method as string) || "GET").toUpperCase();
|
||||
const p = input.replace(/^\/admin\/api/, "");
|
||||
let body: unknown;
|
||||
if (typeof opts.body === "string") {
|
||||
try {
|
||||
body = JSON.parse(opts.body);
|
||||
} catch {
|
||||
body = opts.body;
|
||||
}
|
||||
}
|
||||
fetchCalls.push({ method, path: p, body });
|
||||
if (p === "/setup/status") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ needs_setup: false }),
|
||||
} as Response;
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({}) } as Response;
|
||||
}) as typeof fetch;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("Server/admin/static/index.html — channel permissions save (OC-0154)", () => {
|
||||
let dom: JSDOM | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
dom?.window?.close();
|
||||
dom = undefined;
|
||||
});
|
||||
|
||||
it("keeps the quick 'Can access' toggle's write when the override matrix targets the same role", async () => {
|
||||
const fetchCalls: FetchCall[] = [];
|
||||
dom = loadAdminPanel(fetchCalls);
|
||||
const { window } = dom;
|
||||
|
||||
// Let the page's own bootstrap (checkAuth() -> GET /setup/status) settle
|
||||
// before we start driving it, and drop that call from the log.
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
fetchCalls.length = 0;
|
||||
|
||||
const bridge = (
|
||||
window as unknown as {
|
||||
__test: {
|
||||
state: any;
|
||||
renderChannelPermsModal: () => void;
|
||||
renderPermMatrix: () => void;
|
||||
saveChannelPerms: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
).__test;
|
||||
expect(bridge).toBeTruthy();
|
||||
|
||||
// Open the channel-permissions modal for #general. "Member" has no
|
||||
// existing per-channel override (allow=0, deny=0) — matches the finding's repro.
|
||||
bridge.state.permChannel = {
|
||||
id: 42,
|
||||
name: "general",
|
||||
roles: [{ role_id: 5, role_name: "Member", permissions: 0, allow: 0, deny: 0 }],
|
||||
users: [],
|
||||
allUsers: [],
|
||||
};
|
||||
bridge.renderChannelPermsModal();
|
||||
|
||||
// Pick "Member" in the override-matrix dropdown. With no existing
|
||||
// override every radio renders "Inherit".
|
||||
const targetSelect = window.document.getElementById("permTarget") as HTMLSelectElement;
|
||||
expect(targetSelect).toBeTruthy();
|
||||
targetSelect.value = "r:5";
|
||||
bridge.renderPermMatrix();
|
||||
|
||||
// Untick "Can access" for Member — the quick toggle that should hide the
|
||||
// channel from that role.
|
||||
const accessBox = window.document.getElementById("permRole5") as HTMLInputElement;
|
||||
expect(accessBox).toBeTruthy();
|
||||
accessBox.checked = false;
|
||||
|
||||
await bridge.saveChannelPerms();
|
||||
|
||||
const rolePermCalls = fetchCalls.filter((c) => c.path === "/channels/42/permissions/5");
|
||||
expect(rolePermCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// The quick toggle must have written a deny — and nothing written after
|
||||
// it may delete that override back out. A DELETE here means the matrix
|
||||
// step, working off its pre-toggle (stale) snapshot, just reverted the
|
||||
// channel back to visible for Member.
|
||||
const last = rolePermCalls.at(-1);
|
||||
if (!last) throw new Error("expected a /channels/42/permissions/5 call to assert on");
|
||||
expect(last.method).not.toBe("DELETE");
|
||||
expect((last.body as { deny: number }).deny & 0x2).toBe(0x2);
|
||||
});
|
||||
});
|
||||
@@ -500,6 +500,20 @@ describe("API Client", () => {
|
||||
await expect(api.confirmTotp("pw", "000000")).rejects.toThrow(ApiClientError);
|
||||
});
|
||||
|
||||
it("confirmTotp does NOT call onUnauthorized on a wrong enrollment code, even though the server answers 401", async () => {
|
||||
// Server contract: handleConfirmTOTP answers 401 UNAUTHORIZED /
|
||||
// "invalid two-factor code" for a wrong code — the session itself is
|
||||
// still perfectly valid. Firing the global session-expiry sink here
|
||||
// would sign the user out and (via main.ts) delete their stored
|
||||
// credential over a mistyped enrollment code.
|
||||
mockFetch.mockResolvedValue(errorResponse(401, "UNAUTHORIZED", "invalid two-factor code"));
|
||||
await expect(api.confirmTotp("pw", "000000")).rejects.toMatchObject({
|
||||
status: 401,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
expect(onUnauthorized).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disableTotp throws ApiClientError when 2FA is required", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
errorResponse(403, "TOTP_REQUIRED", "2FA is required by server policy"),
|
||||
|
||||
@@ -526,6 +526,42 @@ describe("ChannelSidebar", () => {
|
||||
expect(userName?.textContent).toBe("Alice");
|
||||
});
|
||||
|
||||
it("shows the member's display name (nickname), not the raw username, in the voice roster", () => {
|
||||
setChannels(testChannels);
|
||||
// Member has a nickname set — every other identity surface (member list,
|
||||
// message rows, DM sidebar) renders it instead of the raw username.
|
||||
membersStore.setState((prev) => ({
|
||||
...prev,
|
||||
members: new Map([
|
||||
[
|
||||
10,
|
||||
{
|
||||
id: 10,
|
||||
username: "Bob",
|
||||
displayName: "Bobby",
|
||||
avatar: null,
|
||||
role: "member",
|
||||
status: "online" as const,
|
||||
},
|
||||
],
|
||||
]),
|
||||
}));
|
||||
updateVoiceState({
|
||||
channel_id: 3,
|
||||
user_id: 10,
|
||||
username: "Bob",
|
||||
muted: false,
|
||||
deafened: false,
|
||||
speaking: false,
|
||||
camera: false,
|
||||
screenshare: false,
|
||||
});
|
||||
sidebar.mount(container);
|
||||
|
||||
const userName = container.querySelector(".voice-user-item .vu-name");
|
||||
expect(userName?.textContent).toBe("Bobby");
|
||||
});
|
||||
|
||||
it("highlights voice channel as active when user is joined", () => {
|
||||
setChannels(testChannels);
|
||||
voiceStore.setState((prev) => ({ ...prev, currentChannelId: 3 }));
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createTypingIndicator } from "@components/TypingIndicator";
|
||||
import { membersStore, setMembers, setTyping } from "@stores/members.store";
|
||||
import type { ReadyMember } from "@lib/types";
|
||||
|
||||
const MEMBER_BOB: ReadyMember = {
|
||||
id: 2,
|
||||
username: "bob",
|
||||
avatar: null,
|
||||
role: "member",
|
||||
status: "online",
|
||||
display_name: "Bobby",
|
||||
};
|
||||
|
||||
const MEMBER_CAROL: ReadyMember = {
|
||||
id: 3,
|
||||
username: "carol",
|
||||
avatar: null,
|
||||
role: "member",
|
||||
status: "online",
|
||||
display_name: "Caro",
|
||||
};
|
||||
|
||||
function resetStore(): void {
|
||||
membersStore.setState(() => ({
|
||||
members: new Map(),
|
||||
typingUsers: new Map(),
|
||||
roleRevision: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("TypingIndicator", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
resetStore();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("renders the member's display name, not the raw username, for a single typer", () => {
|
||||
setMembers([MEMBER_BOB]);
|
||||
setTyping(100, 2);
|
||||
|
||||
const indicator = createTypingIndicator({ channelId: 100, currentUserId: 1 });
|
||||
indicator.mount(container);
|
||||
|
||||
expect(container.textContent).toContain("Bobby is typing...");
|
||||
expect(container.textContent).not.toContain("bob is typing...");
|
||||
|
||||
indicator.destroy?.();
|
||||
});
|
||||
|
||||
it("renders display names, not raw usernames, for two typers", () => {
|
||||
setMembers([MEMBER_BOB, MEMBER_CAROL]);
|
||||
setTyping(100, 2);
|
||||
setTyping(100, 3);
|
||||
|
||||
const indicator = createTypingIndicator({ channelId: 100, currentUserId: 1 });
|
||||
indicator.mount(container);
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Bobby");
|
||||
expect(text).toContain("Caro");
|
||||
expect(text).not.toContain("bob and");
|
||||
expect(text).not.toContain("carol are");
|
||||
|
||||
indicator.destroy?.();
|
||||
});
|
||||
});
|
||||
@@ -559,6 +559,13 @@ describe("markdown with mentions", () => {
|
||||
expect(strong!.querySelector("a")?.getAttribute("href")).toBe("https://example.com/a_b");
|
||||
});
|
||||
|
||||
it("still italicises around a URL with single underscore delimiters", () => {
|
||||
const el = message("_https://example.com/a_");
|
||||
const em = el.querySelector("em");
|
||||
expect(em).not.toBeNull();
|
||||
expect(em!.querySelector("a.msg-link")?.getAttribute("href")).toBe("https://example.com/a");
|
||||
});
|
||||
|
||||
it("combines a heading, a list, a quote and a fence in one message", () => {
|
||||
const el = message("# Title\n- a\n- b\n> note\n```js\nlet x = 1\n```");
|
||||
expect(el.querySelector("h1")).not.toBeNull();
|
||||
|
||||
@@ -1004,6 +1004,22 @@ describe("WS Dispatcher", () => {
|
||||
expect(channelsStore.getState().activeChannelId).toBe(7);
|
||||
});
|
||||
|
||||
it("ready auto-selects an announcement channel when no text channel is visible", () => {
|
||||
// Announcement channels are full message channels everywhere else (the
|
||||
// server ships unread_count/can_send for them, the sidebar renders them,
|
||||
// ChannelController gives them a composer) — a user whose only readable
|
||||
// channel is an announcement channel must still land on it, not a blank
|
||||
// pane, on first ready.
|
||||
mock.dispatch("ready", {
|
||||
channels: [{ id: 9, name: "news", type: "announcement", category: null, position: 0 }],
|
||||
members: [],
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
});
|
||||
|
||||
expect(channelsStore.getState().activeChannelId).toBe(9);
|
||||
});
|
||||
|
||||
it("ready does NOT change active channel when it is still present in the payload", () => {
|
||||
// Regression guard for the auto-select branch: an already-active channel
|
||||
// that is STILL in the new snapshot must not be reassigned to the first
|
||||
@@ -2050,6 +2066,51 @@ describe("WS Dispatcher", () => {
|
||||
expect(channelsStore.getState().activeChannelId).toBeNull();
|
||||
});
|
||||
|
||||
it("wires channel_delete and redirects to an announcement channel when no text channels remain", () => {
|
||||
channelsStore.setState((prev) => {
|
||||
const ch = new Map(prev.channels);
|
||||
ch.set(10, {
|
||||
id: 10,
|
||||
name: "active-ch",
|
||||
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,
|
||||
});
|
||||
ch.set(30, {
|
||||
id: 30,
|
||||
name: "news",
|
||||
type: "announcement" as const,
|
||||
category: null,
|
||||
position: 1,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
lastMessageId: null,
|
||||
canSend: false,
|
||||
topic: "",
|
||||
slowMode: 0,
|
||||
nsfw: false,
|
||||
voiceMaxUsers: 0,
|
||||
voiceMaxVideo: 0,
|
||||
});
|
||||
return { ...prev, channels: ch, activeChannelId: 10 };
|
||||
});
|
||||
|
||||
mock.dispatch("channel_delete", { id: 10 });
|
||||
|
||||
// The only remaining channel is an announcement channel — a readable,
|
||||
// full message channel — not a blank pane.
|
||||
expect(channelsStore.getState().activeChannelId).toBe(30);
|
||||
});
|
||||
|
||||
it("wires member_update to update role", () => {
|
||||
membersStore.setState((prev) => {
|
||||
const m = new Map(prev.members);
|
||||
@@ -3461,6 +3522,49 @@ describe("WS Dispatcher", () => {
|
||||
expect(channelsStore.getState().activeChannelId).toBe(1);
|
||||
});
|
||||
|
||||
it("falls back to an announcement channel when the closed DM was active, no DMs remain, and there is no text channel", () => {
|
||||
dmStore.setState(() => ({
|
||||
channels: [
|
||||
{
|
||||
channelId: 50,
|
||||
recipient: { id: 10, username: "bob", avatar: "", status: "online" },
|
||||
participants: [],
|
||||
name: "",
|
||||
isGroup: false,
|
||||
lastMessageId: null,
|
||||
lastMessage: "",
|
||||
lastMessageAt: "",
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
},
|
||||
],
|
||||
}));
|
||||
channelsStore.setState((prev) => {
|
||||
const ch = new Map(prev.channels);
|
||||
ch.set(30, {
|
||||
id: 30,
|
||||
name: "news",
|
||||
type: "announcement" as const,
|
||||
category: null,
|
||||
position: 0,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
lastMessageId: null,
|
||||
canSend: false,
|
||||
topic: "",
|
||||
slowMode: 0,
|
||||
nsfw: false,
|
||||
voiceMaxUsers: 0,
|
||||
voiceMaxVideo: 0,
|
||||
});
|
||||
return { ...prev, channels: ch, activeChannelId: 50 };
|
||||
});
|
||||
|
||||
mock.dispatch("dm_channel_close", { channel_id: 50 });
|
||||
|
||||
expect(channelsStore.getState().activeChannelId).toBe(30);
|
||||
});
|
||||
|
||||
it("does not change the active channel when the closed DM was not active", () => {
|
||||
dmStore.setState(() => ({
|
||||
channels: [
|
||||
|
||||
@@ -982,6 +982,88 @@ describe("E2EEManager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("[OC-0155] shares the offer-pacing budget across back-to-back rotations instead of resetting it per call", async () => {
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
mockVoiceState.voiceUsers.set(1, new Map([[1, {}]]));
|
||||
await mgr.setupKeyExchange(true, 1); // holder
|
||||
|
||||
// Seed 40 peers — under the server's 60-offer cap for a SINGLE rotation,
|
||||
// but two back-to-back rotations of 40 each (80 offers total) blow
|
||||
// through the server's shared per-second window if each rotation gets
|
||||
// its own fresh pacing budget instead of sharing one.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
mgr.peerPublicKeys.set(2000 + i, { type: `peer-${i}` } as unknown as CryptoKey);
|
||||
}
|
||||
ws.send.mockClear();
|
||||
|
||||
// A second keyed-peer leave lands while this rotation is conceptually
|
||||
// in flight — drainPendingRotationOrArmTimer (livekitE2EE.ts:1256-1263)
|
||||
// runs a second rotation immediately once this one finishes, exactly
|
||||
// like handleParticipantLeft's wasKeyHolder branch does.
|
||||
mgr.rotationPending = true;
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const rotationPromise = mgr.rotateKeyPeriodically();
|
||||
// Let every microtask-bound send that doesn't need a real timer run —
|
||||
// this covers BOTH rotations if neither individually hits the cap.
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const sentBeforePause = sendsOfType(ws, "voice_e2ee_offer").length;
|
||||
// The combined 80 offers across both rotations must not all go out
|
||||
// unpaced just because neither rotation's own 40-offer batch exceeds
|
||||
// the 60 cap in isolation — the budget must be shared.
|
||||
expect(sentBeforePause).toBeLessThanOrEqual(60);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await rotationPromise;
|
||||
|
||||
// Both rotations' offers (40 + 40) eventually go out.
|
||||
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(80);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("[OC-0167] paces announce-driven offers through the same shared budget as rotation offers", async () => {
|
||||
const ws = { send: vi.fn() };
|
||||
const mgr = createManager(ws);
|
||||
|
||||
// 70 existing participants' announces are relayed to the future key
|
||||
// holder before its own keypair is ready — exactly what voiceJoinComplete
|
||||
// does for a joiner elected key holder in a large ongoing channel
|
||||
// (Server/ws/voice_join.go:490-495). They queue.
|
||||
for (let i = 0; i < 70; i++) {
|
||||
await mgr.handleAnnounce(3000 + i, "cGVlcg==", "sig");
|
||||
}
|
||||
expect(mgr.pendingAnnounces).toHaveLength(70);
|
||||
|
||||
mockVoiceState.voiceUsers.set(1, new Map([[1, {}]]));
|
||||
ws.send.mockClear();
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
// setupKeyExchange generates the room key, then drains all 70 queued
|
||||
// announces in a tight loop — each drained announce sends a
|
||||
// voice_e2ee_offer directly (handleAnnounceInner), bypassing
|
||||
// distributeRoomKey's pacing entirely.
|
||||
const setupPromise = mgr.setupKeyExchange(true, 1);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const sentBeforePause = sendsOfType(ws, "voice_e2ee_offer").length;
|
||||
// Must not blow through all 70 announce-driven offers in one unpaced
|
||||
// burst.
|
||||
expect(sentBeforePause).toBeLessThan(70);
|
||||
expect(sentBeforePause).toBeGreaterThan(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await setupPromise;
|
||||
|
||||
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(70);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Ledger findings OC-0010 / OC-0011 ─────────────────────────────────
|
||||
|
||||
it("[OC-0010] does not stand down a new session's key-holder role when a stale offer's setKey resolves after teardown+rejoin", async () => {
|
||||
|
||||
@@ -678,3 +678,52 @@ describe("MainPage — presence", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MainPage — server restart banner", () => {
|
||||
let container: HTMLDivElement;
|
||||
let page: ReturnType<typeof createMainPage>;
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
page?.destroy?.();
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("re-syncs to the real (connected) status instead of counting down to a fake Reconnecting on an update_aborted cancel (OC-0164)", () => {
|
||||
const ws = fakeWs();
|
||||
page = createMainPage({ ws, api: fakeApi() });
|
||||
page.mount(container);
|
||||
|
||||
// Server announces a real restart countdown.
|
||||
ws.emit("server_restart", { reason: "update", delay_seconds: 5 });
|
||||
|
||||
const banner = container.querySelector(".reconnecting-banner") as HTMLElement;
|
||||
expect(banner.classList.contains("visible")).toBe(true);
|
||||
expect(banner.textContent).toBe("Server restarting in 5 seconds...");
|
||||
|
||||
// The staged update then fails to swap; the server cancels the countdown
|
||||
// it already announced with a zero-delay update_aborted broadcast
|
||||
// (Server/admin/update_handlers.go:181). The socket never actually
|
||||
// dropped — uiStore.connectionStatus is still "connected".
|
||||
expect(uiStore.getState().connectionStatus).toBe("connected");
|
||||
ws.emit("server_restart", { reason: "update_aborted", delay_seconds: 0 });
|
||||
|
||||
// The banner must re-sync to the real (connected) status — hidden — not
|
||||
// count down to a permanent "Reconnecting..." over a healthy connection.
|
||||
expect(banner.classList.contains("visible")).toBe(false);
|
||||
|
||||
// And nothing left running should later flip it to Reconnecting either
|
||||
// (the buggy path fed 0 into showRestart's setInterval, which falls
|
||||
// through remaining<=0 into showReconnecting on the very next tick).
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(banner.textContent).not.toBe("Reconnecting...");
|
||||
expect(banner.classList.contains("visible")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +152,8 @@ vi.mock("@lib/dispatcher", async () => {
|
||||
import { mockInvoke, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks";
|
||||
import { clearAuth } from "@stores/auth.store";
|
||||
import { loadUserStatus, loadUserStatusOrigin } from "@lib/userStatus";
|
||||
import { createMainPage } from "@pages/MainPage";
|
||||
import { setActivePresenceSender, type PresenceSender } from "@lib/presence";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import the module under test AFTER all mocks are registered. #app must
|
||||
@@ -213,6 +215,33 @@ describe("main.ts tray status-change listener (OC-0037)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("main.ts tray status-change routes through the shared PresenceSender (OC-0176)", () => {
|
||||
afterEach(() => {
|
||||
setActivePresenceSender(null);
|
||||
});
|
||||
|
||||
it("sends the tray's chosen status through the session's registered PresenceSender, not a raw ws.send", () => {
|
||||
// Stand in for the one PresenceSender MainPage.ts registers for the
|
||||
// session (via setActivePresenceSender) — the shared limiter token, the
|
||||
// coalescing retry, and the optimistic update all live inside it.
|
||||
const fakeSender: PresenceSender = { send: vi.fn(), destroy: vi.fn() };
|
||||
setActivePresenceSender(fakeSender);
|
||||
|
||||
emitTauriEvent("status-change", "dnd");
|
||||
|
||||
// Before the fix, main.ts calls ws.send({ type: "presence_update", ... })
|
||||
// directly — bypassing this sender (and the shared rate-limit budget it
|
||||
// enforces) entirely, so fakeSender.send is never called.
|
||||
expect(fakeSender.send).toHaveBeenCalledExactlyOnceWith("dnd");
|
||||
});
|
||||
|
||||
it("is a safe no-op (no throw) when no session's PresenceSender is registered", () => {
|
||||
setActivePresenceSender(null);
|
||||
|
||||
expect(() => emitTauriEvent("status-change", "idle")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("main.ts connected overlay (OC-0063)", () => {
|
||||
it("shows the auth_ok payload's server_name and motd, not the pre-handshake authStore snapshot", async () => {
|
||||
await loginAndReachAuthOk("192.168.1.10:8443", "alex", {
|
||||
@@ -237,6 +266,70 @@ describe("main.ts connected overlay (OC-0063)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("main.ts connected overlay teardown on mid-handshake session end (OC-0157)", () => {
|
||||
it('destroys the connected overlay when auth clears before the router leaves "connect"', async () => {
|
||||
await loginAndReachAuthOk("mid-handshake.example:8443", "casey", {
|
||||
user: { id: 7, username: "casey", avatar: null, role: "member" },
|
||||
server_name: "Mid Handshake Co",
|
||||
motd: "",
|
||||
});
|
||||
|
||||
// auth_ok landed: the overlay is mounted over #app while the router is
|
||||
// still "connect" — it only moves to "main" from the overlay's own
|
||||
// onReady, 800ms after the `ready` event arrives.
|
||||
expect(document.querySelector('[data-testid="connected-overlay"]')).not.toBeNull();
|
||||
|
||||
// Simulate a session that ends here — a ban, an auth_error on an
|
||||
// intervening reconnect, or a server shutdown — none of which the
|
||||
// client ever receives `ready` for. dispatcher.ts's handlers for all
|
||||
// three do `ws.disconnect(); clearAuth();` before `ready` can arrive.
|
||||
clearAuth();
|
||||
// authStore notifications are microtask-deferred (see store.ts).
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// Before the fix, the isAuthenticated subscriber's gate is
|
||||
// `router.getCurrentPage() === "main"` — since the router never left
|
||||
// "connect", the subscriber no-ops entirely and the overlay
|
||||
// (position:fixed, opaque, z-index 200) is orphaned over the connect
|
||||
// page forever; only an app restart clears it.
|
||||
expect(document.querySelector('[data-testid="connected-overlay"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels the overlay's armed onReady timer when the session ends inside the 800ms ready window", async () => {
|
||||
const mainPageCallsBefore = vi.mocked(createMainPage).mock.calls.length;
|
||||
|
||||
await loginAndReachAuthOk("wide-window.example:8443", "riley", {
|
||||
user: { id: 8, username: "riley", avatar: null, role: "member" },
|
||||
server_name: "Wide Window Co",
|
||||
motd: "",
|
||||
});
|
||||
|
||||
// `ready` arrives and arms the overlay's 800ms onReady timer (which
|
||||
// would otherwise call router.navigate("main") on its own).
|
||||
emitTauriEvent("ws-message", JSON.stringify({ type: "ready", payload: {} }));
|
||||
|
||||
// The ban/shutdown lands partway through that 800ms window — well after
|
||||
// `ready`, well before the timer fires.
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
clearAuth();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(document.querySelector('[data-testid="connected-overlay"]')).toBeNull();
|
||||
|
||||
// Advance past the timer's original 800ms deadline. Before the fix, the
|
||||
// subscriber never called connectedOverlay.destroy() (its AbortController
|
||||
// is what cancels the pending setTimeout — see ConnectedOverlay.ts), so
|
||||
// the already-armed timer still fires onReady() -> router.navigate("main"),
|
||||
// mounting MainPage on a cleared authStore and a disconnected socket even
|
||||
// though the isAuthenticated subscriber already ran and won't run again.
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
|
||||
expect(vi.mocked(createMainPage).mock.calls.length).toBe(mainPageCallsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("main.ts connect-page skip-auto-login flag (OC-0028)", () => {
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
|
||||
@@ -565,6 +565,33 @@ describe("MemberList", () => {
|
||||
expect(eveRow.classList.contains("offline")).toBe(false);
|
||||
});
|
||||
|
||||
it("opens the profile popup with live status after a presence-only patch", () => {
|
||||
setTestMembers(testMembers);
|
||||
memberList.mount(container);
|
||||
|
||||
// Eve starts online; the presence flip to offline is presence-only, so
|
||||
// it takes the patchPresence fast path (row identity preserved, no
|
||||
// renderList/createMemberItem call).
|
||||
updatePresence(5, "offline");
|
||||
membersStore.flush();
|
||||
|
||||
const eveRow = container.querySelector('[data-testid="member-5"]') as HTMLDivElement;
|
||||
expect(eveRow.classList.contains("offline")).toBe(true);
|
||||
|
||||
eveRow.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: 10, clientY: 10 }));
|
||||
|
||||
const popup = document.querySelector('[data-testid="user-profile-popup"]');
|
||||
expect(popup).not.toBeNull();
|
||||
const statusDot = popup!.querySelector(".upp-status-dot") as HTMLDivElement;
|
||||
// Bug: createMemberItem's click handler closes over the render-time
|
||||
// `member` snapshot, which patchPresence never replaces, so the popup
|
||||
// still shows Eve as online instead of the live offline status.
|
||||
expect(statusDot.title).toBe("Offline");
|
||||
|
||||
popup!.remove();
|
||||
document.querySelector('[data-testid="user-profile-overlay"]')?.remove();
|
||||
});
|
||||
|
||||
it("still fully rebuilds when a member's role changes", () => {
|
||||
setTestMembers(testMembers);
|
||||
memberList.mount(container);
|
||||
|
||||
@@ -1311,6 +1311,52 @@ describe("messages store", () => {
|
||||
expect(msgs).toHaveLength(2);
|
||||
expect(msgs.find((m) => m.correlationId === "c1")!.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("keeps id/time order when another user's message commits while our send is still in flight", () => {
|
||||
// Channel loaded with [id 100].
|
||||
addMessage(makeChatPayload({ id: 100, user: TEST_USER_2, content: "seed" }));
|
||||
|
||||
// A types and sends -> optimistic pending row appended at the tail.
|
||||
addOptimisticMessage({
|
||||
correlationId: "c1",
|
||||
channelId: 1,
|
||||
user: TEST_USER,
|
||||
content: "mine",
|
||||
replyTo: null,
|
||||
timestamp: "2026-03-15T10:00:01Z",
|
||||
});
|
||||
|
||||
// Before A's send commits server-side, B's message commits as id 101
|
||||
// and is broadcast. Different author/content, so this cannot reconcile
|
||||
// against the pending row — it must land as a genuine append, and it
|
||||
// must land *before* the still-pending row, not after it, or the
|
||||
// pending row (which will shortly outrank it in id/time) ends up
|
||||
// sitting ahead of an older message.
|
||||
addMessage(
|
||||
makeChatPayload({
|
||||
id: 101,
|
||||
user: TEST_USER_2,
|
||||
content: "bob's message",
|
||||
timestamp: "2026-03-15T10:00:02Z",
|
||||
}),
|
||||
);
|
||||
|
||||
// A's chat_send_ok arrives with the real id, stamped in place.
|
||||
confirmSend("c1", 102, "2026-03-15T10:00:03Z");
|
||||
|
||||
// A's own echo of the broadcast arrives and reconciles by real id.
|
||||
addMessage(
|
||||
makeChatPayload({
|
||||
id: 102,
|
||||
user: TEST_USER,
|
||||
content: "mine",
|
||||
timestamp: "2026-03-15T10:00:03Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const msgs = getChannelMessages(1);
|
||||
expect(msgs.map((m) => m.id)).toEqual([100, 101, 102]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateLoadedMessageWindows", () => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// jsdom never applies app.css, so a computed-style assertion against the
|
||||
// rendered action bar would pass whether or not the rule exists (see
|
||||
// video-grid-track-muted-css.test.ts / base-font-size-css.test.ts for the
|
||||
// same pattern). This pins the CSS *source* instead.
|
||||
//
|
||||
// renderers.ts creates the per-message action bar (.msg-actions-bar) out of
|
||||
// real, focusable <button> elements (msg-react-*, msg-reply-*, msg-pin-*,
|
||||
// msg-edit-*, msg-delete-*, msg-copy-link-*). app.css only reveals that bar
|
||||
// on `.message:hover` -- Tab still walks a keyboard user through the
|
||||
// buttons (they are in the tab order), but they stay `opacity: 0` and
|
||||
// `pointer-events: none` the whole time, so focus is invisible and Enter can
|
||||
// fire an action (e.g. delete) the user never saw highlighted.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe(".msg-actions-bar keyboard-focus visibility", () => {
|
||||
const css = readFileSync(join(process.cwd(), "src/styles/app.css"), "utf8");
|
||||
|
||||
it("app.css reveals .msg-actions-bar when the message has focus-within, not just on hover", () => {
|
||||
// Match a `.message:focus-within .msg-actions-bar { ... }` rule (order of
|
||||
// declarations inside doesn't matter) that sets opacity to 1.
|
||||
const match = /\.message:focus-within\s+\.msg-actions-bar\s*\{([^}]*)\}/.exec(css);
|
||||
expect(
|
||||
match,
|
||||
"expected a `.message:focus-within .msg-actions-bar { ... }` rule in app.css so " +
|
||||
"Tab-focused action buttons (created as real <button>s in renderers.ts) become " +
|
||||
"visible instead of firing invisibly at opacity: 0",
|
||||
).not.toBeNull();
|
||||
|
||||
const body = match![1]!;
|
||||
expect(body, "the focus-within rule must set opacity: 1").toMatch(/opacity\s*:\s*1\b/);
|
||||
expect(
|
||||
body,
|
||||
"the focus-within rule must re-enable pointer-events so the now-visible buttons are clickable",
|
||||
).toMatch(/pointer-events\s*:\s*auto\b/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// OC-0156: createPresenceSender's send() clears any pending retry and
|
||||
// re-arms it carrying only its own `customStatus` argument. A plain status
|
||||
// change (customStatus === undefined) landing while a custom-status commit
|
||||
// is still queued behind the shared limiter must not erase the queued
|
||||
// custom_status — the retry must still carry the last customStatus the user
|
||||
// committed.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { authStore } from "@stores/auth.store";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import { createPresenceSender } from "@lib/presence";
|
||||
import { createPresenceLimiter } from "@lib/rate-limiter";
|
||||
import { saveUserStatus } from "@lib/userStatus";
|
||||
import type { WsClient } from "@lib/ws";
|
||||
|
||||
function createMockWs(): WsClient {
|
||||
return {
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
send: vi.fn(),
|
||||
on: vi.fn().mockReturnValue(() => {}),
|
||||
onStateChange: vi.fn().mockReturnValue(() => {}),
|
||||
startCertListener: vi.fn().mockResolvedValue(undefined),
|
||||
onCertFirstUse: vi.fn().mockReturnValue(() => {}),
|
||||
onCertMismatch: vi.fn().mockReturnValue(() => {}),
|
||||
acceptCertFingerprint: vi.fn(),
|
||||
getState: vi.fn(() => "connected"),
|
||||
isReplaying: vi.fn(() => false),
|
||||
_getWs: vi.fn(() => null),
|
||||
} as unknown as WsClient;
|
||||
}
|
||||
|
||||
describe("createPresenceSender — custom_status survival across supersession (OC-0156)", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
authStore.setState(() => ({
|
||||
token: "tok",
|
||||
user: { id: 1, username: "alice", avatar: null, role: "member" },
|
||||
serverName: "TestServer",
|
||||
motd: null,
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
membersStore.setState(() => ({
|
||||
members: new Map([
|
||||
[
|
||||
1,
|
||||
{
|
||||
id: 1,
|
||||
username: "alice",
|
||||
displayName: null,
|
||||
avatar: null,
|
||||
role: "member",
|
||||
status: "online",
|
||||
customStatus: undefined,
|
||||
} as never,
|
||||
],
|
||||
]),
|
||||
typingUsers: new Map(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
authStore.setState(() => ({
|
||||
token: null,
|
||||
user: null,
|
||||
serverName: null,
|
||||
motd: null,
|
||||
isAuthenticated: false,
|
||||
}));
|
||||
membersStore.setState(() => ({ members: new Map(), typingUsers: new Map() }));
|
||||
});
|
||||
|
||||
it("does not drop a queued custom_status when a later plain status change supersedes it before the retry fires", () => {
|
||||
vi.useFakeTimers();
|
||||
const ws = createMockWs();
|
||||
const sender = createPresenceSender(ws, createPresenceLimiter());
|
||||
try {
|
||||
// t=0s: plain status change consumes the shared limiter's only token.
|
||||
// Mirrors UserBar.ts's onStatusChange, which persists the picked
|
||||
// status before calling sender.send(status) — the queued retry's
|
||||
// `loadUserStatus()` re-read depends on that.
|
||||
saveUserStatus("idle");
|
||||
sender.send("idle");
|
||||
expect(ws.send).toHaveBeenCalledOnce();
|
||||
expect((ws.send as ReturnType<typeof vi.fn>).mock.calls[0]![0]).toEqual({
|
||||
type: "presence_update",
|
||||
payload: { status: "idle" },
|
||||
});
|
||||
(ws.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
// t=2s: custom-status commit — window still closed, so it queues a
|
||||
// retry carrying "Working on OwnCord".
|
||||
vi.advanceTimersByTime(2_000);
|
||||
sender.send("idle", "Working on OwnCord");
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
|
||||
// t=4s: a plain status change (customStatus === undefined) supersedes
|
||||
// the queued retry. Mirrors UserBar.ts's onStatusChange again.
|
||||
vi.advanceTimersByTime(2_000);
|
||||
saveUserStatus("dnd");
|
||||
sender.send("dnd");
|
||||
expect(ws.send).not.toHaveBeenCalled();
|
||||
|
||||
// t=10s: the coalesced retry fires. It must still carry the
|
||||
// custom_status text committed at t=2s — the plain "dnd" call at t=4s
|
||||
// never mentioned custom_status and must not be read as "clear it".
|
||||
vi.advanceTimersByTime(6_000);
|
||||
|
||||
expect(ws.send).toHaveBeenCalledOnce();
|
||||
const sentMsg = (ws.send as ReturnType<typeof vi.fn>).mock.calls[0]![0];
|
||||
expect(sentMsg.type).toBe("presence_update");
|
||||
expect(sentMsg.payload.status).toBe("dnd");
|
||||
expect(sentMsg.payload.custom_status).toBe("Working on OwnCord");
|
||||
} finally {
|
||||
sender.destroy?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -25,8 +25,11 @@ let mockCurrentChannelId: number | null = null;
|
||||
let mockLocalMuted = false;
|
||||
let mockLocalDeafened = false;
|
||||
let mockPttGated = false;
|
||||
let mockPttPollingLive = false;
|
||||
const mockSetPttGated = vi.fn();
|
||||
const mockSetPttPollingLive = vi.fn();
|
||||
const mockSetPttPollingLive = vi.fn((live: boolean) => {
|
||||
mockPttPollingLive = live;
|
||||
});
|
||||
|
||||
/** Captures the listener passed to voiceStore.subscribe() so tests can fire
|
||||
* a simulated store notification (real createStore() batches these via
|
||||
@@ -69,7 +72,8 @@ vi.mock("@stores/voice.store", () => ({
|
||||
subscribe: (listener: (state: { localMuted: boolean }) => void) => mockSubscribeStore(listener),
|
||||
},
|
||||
setPttGated: (...args: unknown[]) => mockSetPttGated(...args),
|
||||
setPttPollingLive: (...args: unknown[]) => mockSetPttPollingLive(...args),
|
||||
setPttPollingLive: (live: boolean) => mockSetPttPollingLive(live),
|
||||
isPttPollingLive: () => mockPttPollingLive,
|
||||
}));
|
||||
|
||||
vi.mock("@lib/logger", () => ({
|
||||
@@ -103,8 +107,12 @@ function resetAll(): void {
|
||||
mockLocalMuted = false;
|
||||
mockLocalDeafened = false;
|
||||
mockPttGated = false;
|
||||
mockPttPollingLive = false;
|
||||
mockSetPttGated.mockReset();
|
||||
mockSetPttPollingLive.mockReset();
|
||||
mockSetPttPollingLive.mockImplementation((live: boolean) => {
|
||||
mockPttPollingLive = live;
|
||||
});
|
||||
mockInvoke.mockReset();
|
||||
mockListen.mockReset();
|
||||
mockSubscribeStore.mockReset();
|
||||
@@ -500,6 +508,107 @@ describe("updatePttKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: updatePttKey gates an already-hot mic when bound mid-call (OC-0162)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("updatePttKey gates the mic when binding a key mid-call (OC-0162)", () => {
|
||||
beforeEach(async () => {
|
||||
resetAll();
|
||||
// The module-level `listening` flag is not reset by resetAll() (it lives
|
||||
// in ptt.ts, not in the mocks) and earlier describe blocks may leave it
|
||||
// true — drain it so each test here starts from the same "no key bound
|
||||
// yet" state the finding's repro assumes.
|
||||
await stopPtt();
|
||||
mockInvoke.mockClear();
|
||||
});
|
||||
|
||||
it("gates and mutes the mic when a key is bound while already in a voice call", async () => {
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockClear();
|
||||
|
||||
// Already in a voice call, joined with no PTT key bound — the mic was
|
||||
// published ungated (pttArmed was false at join time).
|
||||
mockCurrentChannelId = 7;
|
||||
mockPttGated = false;
|
||||
mockLocalMuted = false;
|
||||
mockLocalDeafened = false;
|
||||
mockInvoke.mockImplementation((cmd: string) =>
|
||||
Promise.resolve(cmd === "ptt_polling_supported" ? true : undefined),
|
||||
);
|
||||
|
||||
// The user now binds a PTT key from Settings -> Keybinds.
|
||||
await updatePttKey(0x20);
|
||||
|
||||
// Without the fix, updatePttKey only starts the poller (initPtt) and
|
||||
// never applies the gate — the idle key produces no ptt-state transition
|
||||
// (see src-tauri/src/ptt.rs ptt_transition), so the mic stays hot forever
|
||||
// until the user's first physical press+release.
|
||||
expect(mockSetPttGated).toHaveBeenCalledWith(true);
|
||||
await vi.waitFor(() => {
|
||||
expect(mockSetMuted).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not gate the mic when binding a key while not in a voice call", async () => {
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockClear();
|
||||
|
||||
mockCurrentChannelId = null; // not in a call
|
||||
mockPttGated = false;
|
||||
mockInvoke.mockImplementation((cmd: string) =>
|
||||
Promise.resolve(cmd === "ptt_polling_supported" ? true : undefined),
|
||||
);
|
||||
|
||||
await updatePttKey(0x20);
|
||||
|
||||
expect(mockSetPttGated).not.toHaveBeenCalledWith(true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mockSetMuted).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("does not gate the mic when the backend cannot actually observe key state", async () => {
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockClear();
|
||||
|
||||
mockCurrentChannelId = 7; // in a call
|
||||
mockPttGated = false;
|
||||
// ptt_polling_supported === false: macOS is_key_down stub / Wayland — no
|
||||
// ptt-state event can ever arrive, so gating here would strand the mic
|
||||
// muted forever with no press able to lift it.
|
||||
mockInvoke.mockImplementation((cmd: string) =>
|
||||
Promise.resolve(cmd === "ptt_polling_supported" ? false : undefined),
|
||||
);
|
||||
|
||||
await updatePttKey(0x20);
|
||||
|
||||
expect(mockSetPttGated).not.toHaveBeenCalledWith(true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(mockSetMuted).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("does not re-gate when the mic is already PTT-gated", async () => {
|
||||
const { setMuted } = await import("../../src/lib/livekitSession");
|
||||
const mockSetMuted = vi.mocked(setMuted);
|
||||
mockSetMuted.mockClear();
|
||||
|
||||
mockCurrentChannelId = 7;
|
||||
mockPttGated = true; // already gated (e.g. join-time gate already armed)
|
||||
mockInvoke.mockImplementation((cmd: string) =>
|
||||
Promise.resolve(cmd === "ptt_polling_supported" ? true : undefined),
|
||||
);
|
||||
|
||||
await updatePttKey(0x20);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(mockSetPttGated).not.toHaveBeenCalledWith(true);
|
||||
expect(mockSetMuted).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests: captureKeyPress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Keyboard activation for reaction pills (OC-0186).
|
||||
*
|
||||
* The chip carries tabindex="0" so it is reachable via Tab, but a bare
|
||||
* <span> has no native key activation. Enter/Space on a focused pill must
|
||||
* mirror the click, and the "+" add-reaction chip must be reachable and
|
||||
* activatable the same way.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("@lib/livekitSession", () => ({
|
||||
leaveVoice: vi.fn(),
|
||||
switchInputDevice: vi.fn(),
|
||||
switchOutputDevice: vi.fn(),
|
||||
setVoiceSensitivity: vi.fn(),
|
||||
setInputVolume: vi.fn(),
|
||||
setOutputVolume: vi.fn(),
|
||||
getSessionDebugInfo: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
import { renderReactions } from "../../src/components/message-list/reactions";
|
||||
import type { Message } from "../../src/stores/messages.store";
|
||||
import type { MessageListOptions } from "../../src/components/MessageList";
|
||||
|
||||
function makeMessage(): Message {
|
||||
return {
|
||||
id: 1,
|
||||
channelId: 1,
|
||||
userId: 10,
|
||||
username: "alice",
|
||||
avatar: null,
|
||||
content: "hi",
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
editedAt: null,
|
||||
replyTo: null,
|
||||
attachments: [],
|
||||
reactions: [{ emoji: "🔥", count: 2, me: false }],
|
||||
pending: false,
|
||||
failed: false,
|
||||
pinned: false,
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
function reactionOptions(): MessageListOptions {
|
||||
return { onReactionClick: vi.fn() } as unknown as MessageListOptions;
|
||||
}
|
||||
|
||||
function fireKey(el: Element, key: string): void {
|
||||
el.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
describe("reaction pill keyboard activation (OC-0186)", () => {
|
||||
it("toggles the reaction on Enter when the pill is focused", () => {
|
||||
const opts = reactionOptions();
|
||||
const el = renderReactions(makeMessage(), opts, new AbortController().signal);
|
||||
const chip = el.querySelector(".reaction-chip") as HTMLElement;
|
||||
fireKey(chip, "Enter");
|
||||
expect(opts.onReactionClick).toHaveBeenCalledWith(1, "🔥");
|
||||
});
|
||||
|
||||
it("toggles the reaction on Space when the pill is focused", () => {
|
||||
const opts = reactionOptions();
|
||||
const el = renderReactions(makeMessage(), opts, new AbortController().signal);
|
||||
const chip = el.querySelector(".reaction-chip") as HTMLElement;
|
||||
fireKey(chip, " ");
|
||||
expect(opts.onReactionClick).toHaveBeenCalledWith(1, "🔥");
|
||||
});
|
||||
|
||||
it("advertises button semantics on the reaction pill", () => {
|
||||
const el = renderReactions(makeMessage(), reactionOptions(), new AbortController().signal);
|
||||
const chip = el.querySelector(".reaction-chip");
|
||||
expect(chip?.getAttribute("role")).toBe("button");
|
||||
});
|
||||
|
||||
it("makes the add-reaction chip focusable and keyboard-activatable", () => {
|
||||
const opts = reactionOptions();
|
||||
const el = renderReactions(makeMessage(), opts, new AbortController().signal);
|
||||
const addBtn = el.querySelector(".add-reaction") as HTMLElement;
|
||||
expect(addBtn.getAttribute("tabindex")).toBe("0");
|
||||
expect(addBtn.getAttribute("role")).toBe("button");
|
||||
fireKey(addBtn, "Enter");
|
||||
expect(opts.onReactionClick).toHaveBeenCalledWith(1, "");
|
||||
});
|
||||
});
|
||||
@@ -908,6 +908,44 @@ describe("ServerPanel", () => {
|
||||
expect(onAddProfile).toHaveBeenCalledWith("Port-less", "myserver.example.com");
|
||||
});
|
||||
|
||||
it("accepts a bracketed IPv6 host with port (OC-0187)", () => {
|
||||
const onAddProfile = vi.fn();
|
||||
const panel = createServerPanel(makeOpts({ onAddProfile }), SIMPLE_PROFILES);
|
||||
container.appendChild(panel.element);
|
||||
|
||||
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
||||
addBtn.click();
|
||||
|
||||
const inputs = container.querySelectorAll(".form-input") as NodeListOf<HTMLInputElement>;
|
||||
inputs[0]!.value = "v6";
|
||||
inputs[1]!.value = "[::1]:8443";
|
||||
|
||||
const saveBtn = container.querySelector(".modal-footer .btn-primary") as HTMLElement;
|
||||
saveBtn.click();
|
||||
|
||||
expect(onAddProfile).toHaveBeenCalledWith("v6", "[::1]:8443");
|
||||
expect(container.querySelector(".modal-overlay")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a bare (unbracketed) IPv6 host without port (OC-0187)", () => {
|
||||
const onAddProfile = vi.fn();
|
||||
const panel = createServerPanel(makeOpts({ onAddProfile }), SIMPLE_PROFILES);
|
||||
container.appendChild(panel.element);
|
||||
|
||||
const addBtn = container.querySelector(".btn-add-server") as HTMLElement;
|
||||
addBtn.click();
|
||||
|
||||
const inputs = container.querySelectorAll(".form-input") as NodeListOf<HTMLInputElement>;
|
||||
inputs[0]!.value = "v6-bare";
|
||||
inputs[1]!.value = "2001:db8::1";
|
||||
|
||||
const saveBtn = container.querySelector(".modal-footer .btn-primary") as HTMLElement;
|
||||
saveBtn.click();
|
||||
|
||||
expect(onAddProfile).toHaveBeenCalledWith("v6-bare", "2001:db8::1");
|
||||
expect(container.querySelector(".modal-overlay")).toBeNull();
|
||||
});
|
||||
|
||||
it("submits on Enter key in host input", () => {
|
||||
const onAddProfile = vi.fn();
|
||||
const panel = createServerPanel(makeOpts({ onAddProfile }), SIMPLE_PROFILES);
|
||||
|
||||
@@ -36,11 +36,21 @@ vi.mock("@lib/livekitSession", () => ({
|
||||
getSessionDebugInfo: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// Held in a mutable object (rather than baked into the factory literal) so
|
||||
// individual tests can simulate the store having a display_name set — e.g.
|
||||
// to reproduce the header/username desync in OC-0188.
|
||||
const mockAuthState = vi.hoisted(() => ({
|
||||
user: {
|
||||
id: 1,
|
||||
username: "testuser",
|
||||
totp_enabled: false,
|
||||
display_name: null as string | null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@stores/auth.store", () => ({
|
||||
authStore: {
|
||||
getState: () => ({
|
||||
user: { id: 1, username: "testuser", totp_enabled: false },
|
||||
}),
|
||||
getState: () => mockAuthState,
|
||||
subscribeSelector: vi.fn(() => () => {}),
|
||||
},
|
||||
updateUser: vi.fn(),
|
||||
@@ -79,6 +89,7 @@ describe("SettingsOverlay", () => {
|
||||
document.body.appendChild(container);
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
mockAuthState.user = { id: 1, username: "testuser", totp_enabled: false, display_name: null };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -905,6 +916,51 @@ describe("SettingsOverlay", () => {
|
||||
overlay.destroy?.();
|
||||
});
|
||||
|
||||
// OC-0188: renaming the username must not stomp the profile card's
|
||||
// header with the raw username when a display name is set — the header
|
||||
// is a resolveDisplayName() slot, not a mirror of whichever field was
|
||||
// last saved.
|
||||
it("keeps the display name in the header after a username rename", async () => {
|
||||
mockAuthState.user = {
|
||||
id: 1,
|
||||
username: "testuser",
|
||||
totp_enabled: false,
|
||||
display_name: "Alice Smith",
|
||||
};
|
||||
|
||||
const overlay = createSettingsOverlay(defaultOptions);
|
||||
overlay.mount(container);
|
||||
|
||||
// Header starts out showing the display name, not the username.
|
||||
const acName = container.querySelector(".account-header-name");
|
||||
expect(acName?.textContent).toBe("Alice Smith");
|
||||
|
||||
const editBtn = container.querySelector(".account-field-edit") as HTMLElement;
|
||||
editBtn.click();
|
||||
|
||||
const editInput = container.querySelector(
|
||||
'[data-testid="username-edit-input"]',
|
||||
) as HTMLInputElement;
|
||||
editInput.value = "alice2";
|
||||
|
||||
const saveBtn = Array.from(container.querySelectorAll(".ac-btn")).find(
|
||||
(b) => b.textContent === "Save",
|
||||
) as HTMLElement;
|
||||
saveBtn.click();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(defaultOptions.onUpdateProfile).toHaveBeenCalledWith({ username: "alice2" });
|
||||
});
|
||||
|
||||
// The server merge leaves display_name untouched; the header must keep
|
||||
// showing it rather than the raw username that was just saved.
|
||||
await vi.waitFor(() => {
|
||||
expect(acName?.textContent).toBe("Alice Smith");
|
||||
});
|
||||
|
||||
overlay.destroy?.();
|
||||
});
|
||||
|
||||
// --- Status selector ---
|
||||
|
||||
// Phase 6 flipped this: "invisible" is a real, settable status now (the
|
||||
@@ -1073,3 +1129,79 @@ describe("SettingsOverlay", () => {
|
||||
expect(container.querySelector(".settings-overlay")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OC-0181: mount() when the store already reports settingsOpen === true
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// ConnectPage creates the overlay lazily: it only calls mount() once
|
||||
// uiStore.settingsOpen is already true (the settings gear flips the store
|
||||
// first, and only then does ensureSettingsOverlay() run). So on the very
|
||||
// first open from the connect page, mount() runs its "sync initial state"
|
||||
// show() call synchronously inside mount() itself — before the caller has
|
||||
// had a chance to see `root` come back and attach it anywhere.
|
||||
//
|
||||
// The module-level mock above pins settingsOpen to a permanent `false`, so
|
||||
// every other test in this file mounts into a closed overlay and only calls
|
||||
// open() afterward (root already attached by then). This block re-imports
|
||||
// the component fresh with settingsOpen already true at mount time, the one
|
||||
// path that exercises the bug.
|
||||
describe("SettingsOverlay - mount() with settingsOpen already true", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("@stores/ui.store");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("moves focus into the dialog when the store is already open at mount time", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@stores/ui.store", () => ({
|
||||
uiStore: {
|
||||
getState: () => ({ settingsOpen: true }),
|
||||
subscribe: () => () => {},
|
||||
subscribeSelector: vi.fn((_sel: unknown, _listener: unknown) => () => {}),
|
||||
},
|
||||
setTheme: vi.fn(),
|
||||
}));
|
||||
|
||||
const { createSettingsOverlay: createReopenedOverlay } =
|
||||
await import("@components/SettingsOverlay");
|
||||
|
||||
const opener = document.createElement("button");
|
||||
document.body.appendChild(opener);
|
||||
opener.focus();
|
||||
expect(document.activeElement).toBe(opener);
|
||||
|
||||
const localContainer = document.createElement("div");
|
||||
document.body.appendChild(localContainer);
|
||||
|
||||
const overlay = createReopenedOverlay({
|
||||
onClose: vi.fn(),
|
||||
onChangePassword: vi.fn().mockResolvedValue(undefined),
|
||||
onUpdateProfile: vi.fn().mockResolvedValue(undefined),
|
||||
onUploadAvatar: vi.fn().mockResolvedValue("/api/v1/files/test"),
|
||||
onLogout: vi.fn(),
|
||||
onDeleteAccount: vi.fn().mockResolvedValue(undefined),
|
||||
onStatusChange: vi.fn(),
|
||||
onEnableTotp: vi.fn().mockResolvedValue({ qr_uri: "otpauth://test", backup_codes: [] }),
|
||||
onConfirmTotp: vi.fn().mockResolvedValue(undefined),
|
||||
onDisableTotp: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
overlay.mount(localContainer);
|
||||
|
||||
// The overlay must actually be in the document by the time mount()
|
||||
// returns, or nothing below it can matter.
|
||||
expect(document.body.contains(localContainer.querySelector(".settings-overlay"))).toBe(true);
|
||||
|
||||
const panel = localContainer.querySelector(".settings-panel") as HTMLElement;
|
||||
expect(panel).not.toBeNull();
|
||||
// focusDialog() only succeeds once root is attached to the document;
|
||||
// called against a detached subtree, .focus() is a silent no-op and
|
||||
// activeElement never leaves the opener button.
|
||||
expect(panel.contains(document.activeElement)).toBe(true);
|
||||
|
||||
overlay.destroy?.();
|
||||
localContainer.remove();
|
||||
opener.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { dmStore, addDmChannel } from "../../src/stores/dm.store";
|
||||
import { uiStore } from "../../src/stores/ui.store";
|
||||
import type { DmChannel } from "../../src/stores/dm.store";
|
||||
import { muteChannel, invalidateMuteCache } from "../../src/lib/channel-mutes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store reset
|
||||
@@ -65,6 +66,8 @@ describe("SidebarDmSection", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
resetStores();
|
||||
localStorage.clear();
|
||||
invalidateMuteCache();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
@@ -332,6 +335,68 @@ describe("SidebarDmSection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Mention badge (OC-0189)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe("mention badge", () => {
|
||||
it("shows a mention badge instead of the unread badge when mentionCount > 0", () => {
|
||||
addDmChannel(makeDm({ channelId: 100, unreadCount: 5, mentionCount: 2 }));
|
||||
|
||||
const section = createSidebarDmSection(defaultOpts());
|
||||
container.appendChild(section.element);
|
||||
|
||||
const mentionBadge = container.querySelector(".dm-mention-badge");
|
||||
expect(mentionBadge).not.toBeNull();
|
||||
expect(mentionBadge!.textContent).toBe("2");
|
||||
|
||||
// The plain unread badge must not also render alongside it.
|
||||
const unreadBadge = container.querySelector(".dm-unread-badge");
|
||||
expect(unreadBadge).toBeNull();
|
||||
|
||||
section.destroy();
|
||||
});
|
||||
|
||||
it("still shows the mention badge on a muted DM (a mute must never hide a mention)", () => {
|
||||
addDmChannel(makeDm({ channelId: 100, unreadCount: 5, mentionCount: 2 }));
|
||||
muteChannel(100);
|
||||
|
||||
const section = createSidebarDmSection(defaultOpts());
|
||||
container.appendChild(section.element);
|
||||
|
||||
const mentionBadge = container.querySelector(".dm-mention-badge");
|
||||
expect(mentionBadge).not.toBeNull();
|
||||
expect(mentionBadge!.textContent).toBe("2");
|
||||
|
||||
section.destroy();
|
||||
});
|
||||
|
||||
it("counts a muted DM's mentions (not its raw unreads) toward the header badge", () => {
|
||||
// Muted DM with a mention: the header must still surface the mention.
|
||||
addDmChannel(makeDm({ channelId: 100, unreadCount: 5, mentionCount: 2 }));
|
||||
muteChannel(100);
|
||||
// Muted DM with no mention: contributes nothing, same as before.
|
||||
addDmChannel(
|
||||
makeDm({
|
||||
channelId: 101,
|
||||
recipient: { id: 11, username: "Bob", avatar: "", status: "online" },
|
||||
unreadCount: 3,
|
||||
mentionCount: 0,
|
||||
}),
|
||||
);
|
||||
muteChannel(101);
|
||||
|
||||
const section = createSidebarDmSection(defaultOpts());
|
||||
container.appendChild(section.element);
|
||||
|
||||
const badge = container.querySelector(".dm-header-unread-badge") as HTMLElement;
|
||||
expect(badge.textContent).toBe("2");
|
||||
expect(badge.style.display).not.toBe("none");
|
||||
|
||||
section.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// View All button
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -3,23 +3,30 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
let storeCallback: (() => void) | null = null;
|
||||
let typingUsers: Array<{ id: number; username: string }> = [];
|
||||
|
||||
vi.mock("@stores/members.store", () => ({
|
||||
membersStore: {
|
||||
subscribe: vi.fn((cb: () => void) => {
|
||||
storeCallback = cb;
|
||||
return () => {
|
||||
storeCallback = null;
|
||||
};
|
||||
}),
|
||||
subscribeSelector: vi.fn((_sel: unknown, listener: () => void) => {
|
||||
storeCallback = listener;
|
||||
return () => {
|
||||
storeCallback = null;
|
||||
};
|
||||
}),
|
||||
},
|
||||
getTypingUsers: vi.fn(() => typingUsers),
|
||||
}));
|
||||
vi.mock("@stores/members.store", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@stores/members.store")>();
|
||||
return {
|
||||
membersStore: {
|
||||
subscribe: vi.fn((cb: () => void) => {
|
||||
storeCallback = cb;
|
||||
return () => {
|
||||
storeCallback = null;
|
||||
};
|
||||
}),
|
||||
subscribeSelector: vi.fn((_sel: unknown, listener: () => void) => {
|
||||
storeCallback = listener;
|
||||
return () => {
|
||||
storeCallback = null;
|
||||
};
|
||||
}),
|
||||
},
|
||||
getTypingUsers: vi.fn(() => typingUsers),
|
||||
// Real implementation, not a stub: TypingIndicator renders through this
|
||||
// helper, and the fixtures below (id/username only, no displayName) must
|
||||
// fall through to `username` exactly as production members do.
|
||||
memberDisplayName: actual.memberDisplayName,
|
||||
};
|
||||
});
|
||||
|
||||
import { createTypingIndicator } from "@components/TypingIndicator";
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ interface VoiceStateStub {
|
||||
localDeafened: boolean;
|
||||
localCamera: boolean;
|
||||
localScreenshare: boolean;
|
||||
localServerMuted: boolean;
|
||||
localServerDeafened: boolean;
|
||||
}
|
||||
|
||||
function makeVoiceState(overrides: Partial<VoiceStateStub> = {}): VoiceStateStub {
|
||||
@@ -99,6 +101,8 @@ function makeVoiceState(overrides: Partial<VoiceStateStub> = {}): VoiceStateStub
|
||||
localDeafened: false,
|
||||
localCamera: false,
|
||||
localScreenshare: false,
|
||||
localServerMuted: false,
|
||||
localServerDeafened: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -221,6 +225,26 @@ describe("createVoiceWidgetCallbacks", () => {
|
||||
expect(ws.send).toHaveBeenCalledWith({ type: "voice_deafen", payload: { deafened: true } });
|
||||
expect(ws.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: "voice_mute" }));
|
||||
});
|
||||
|
||||
it("does not send voice_mute{muted:false} on undeafen while server-muted (OC-0179)", () => {
|
||||
// Mirrors onMuteToggle's localServerMuted guard: a moderator-imposed
|
||||
// mute is not ours to lift, so undeafening must not spend a doomed
|
||||
// voice_mute round-trip that the server will refuse with SERVER_MUTED.
|
||||
mockVoiceStoreGetState.mockReturnValue(
|
||||
makeVoiceState({ localDeafened: true, localMuted: true, localServerMuted: true }),
|
||||
);
|
||||
const ws = makeWs();
|
||||
const cbs = createVoiceWidgetCallbacks(ws, makeLimiters());
|
||||
|
||||
cbs.onDeafenToggle();
|
||||
|
||||
// The deafen clear itself still goes through...
|
||||
expect(mockSetDeafened).toHaveBeenCalledWith(false);
|
||||
expect(ws.send).toHaveBeenCalledWith({ type: "voice_deafen", payload: { deafened: false } });
|
||||
// ...but the unmute must be suppressed while the server mute stands.
|
||||
expect(mockSetMuted).not.toHaveBeenCalled();
|
||||
expect(ws.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: "voice_mute" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("onCameraToggle", () => {
|
||||
|
||||
@@ -52,6 +52,36 @@ describe("WebSocket Client (Tauri proxy)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// OC-0163: a bare (unbracketed) IPv6 host is accepted by api.ts's
|
||||
// isValidHost and successfully dials over REST (the Rust http proxy
|
||||
// brackets it for the TCP dial target), but raw string interpolation here
|
||||
// produced an unparseable authority ("wss://2001:db8::1/..." — host
|
||||
// "2001", port "db8::1") that tokio-tungstenite's URL parser rejects,
|
||||
// leaving the user logged in with a socket that can never open.
|
||||
it("brackets a bare IPv6 host when building the ws_connect URL (OC-0163)", async () => {
|
||||
client.connect({ host: "2001:db8::1", token: "test-token" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(mockInvoke).toHaveBeenCalledWith("ws_connect", {
|
||||
url: "wss://[2001:db8::1]/api/v1/ws",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not double-bracket a host already bracketed by the caller (OC-0163)", async () => {
|
||||
client.connect({ host: "[2001:db8::1]:8443", token: "test-token" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(mockInvoke).toHaveBeenCalledWith("ws_connect", {
|
||||
url: "wss://[2001:db8::1]:8443/api/v1/ws",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a plain DNS host:port untouched (OC-0163 regression guard)", async () => {
|
||||
client.connect({ host: "example.com:8443", token: "test-token" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(mockInvoke).toHaveBeenCalledWith("ws_connect", {
|
||||
url: "wss://example.com:8443/api/v1/ws",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends auth message when Rust reports open", async () => {
|
||||
client.connect({ host: "localhost:8443", token: "test-token" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
@@ -390,6 +390,81 @@ describe("handleMessage size boundary", () => {
|
||||
emitTauriEvent("ws-message", smallMsg);
|
||||
expect(messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not drop an oversized `ready` frame — the handshake payload has no seq and no retry path (OC-0160)", async () => {
|
||||
const limit = 200;
|
||||
client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
emitTauriEvent("ws-state", "open");
|
||||
|
||||
const readyMessages: unknown[] = [];
|
||||
client.on("ready", (p) => readyMessages.push(p));
|
||||
|
||||
const msg = {
|
||||
type: "ready",
|
||||
payload: {
|
||||
channels: [],
|
||||
// Padding well past `limit` — a real `ready` grows unbounded with the
|
||||
// server's member/channel/DM counts and carries no seq, so unlike a
|
||||
// sequenced frame nothing ever re-requests it after a drop.
|
||||
members: Array.from({ length: 20 }, (_, i) => ({ id: i, username: `user${i}` })),
|
||||
voice_states: [],
|
||||
roles: [],
|
||||
},
|
||||
};
|
||||
const json = JSON.stringify(msg);
|
||||
expect(json.length).toBeGreaterThan(limit);
|
||||
|
||||
emitTauriEvent("ws-message", json);
|
||||
expect(readyMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not drop an oversized `auth_ok` frame (handshake exempt from size limit, OC-0160)", async () => {
|
||||
const limit = 100;
|
||||
client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
emitTauriEvent("ws-state", "open");
|
||||
|
||||
const msg = {
|
||||
type: "auth_ok",
|
||||
payload: {
|
||||
user: { id: 1, username: "a".repeat(limit), avatar: null, role: "admin" },
|
||||
server_name: "S",
|
||||
motd: "",
|
||||
},
|
||||
};
|
||||
const json = JSON.stringify(msg);
|
||||
expect(json.length).toBeGreaterThan(limit);
|
||||
|
||||
emitTauriEvent("ws-message", json);
|
||||
expect(client.getState()).toBe("connected");
|
||||
});
|
||||
|
||||
it("still drops an oversized regular (non-handshake) frame (OC-0160 regression guard)", async () => {
|
||||
const limit = 100;
|
||||
client.connect({ host: "localhost:8443", token: "t", maxMessageSizeBytes: limit });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
emitTauriEvent("ws-state", "open");
|
||||
|
||||
const messages: unknown[] = [];
|
||||
client.on("chat_message", (p) => messages.push(p));
|
||||
|
||||
const msg = {
|
||||
type: "chat_message",
|
||||
payload: {
|
||||
id: 1,
|
||||
channel_id: 1,
|
||||
user: { id: 1, username: "a", avatar: null },
|
||||
content: "x".repeat(limit),
|
||||
reply_to: null,
|
||||
attachments: [],
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
|
||||
emitTauriEvent("ws-message", JSON.stringify(msg));
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatch with no listeners for type", () => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package admin_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/admin"
|
||||
@@ -67,3 +70,60 @@ func TestAdminAPI_PatchChannel_UnarchiveDoesNotCleanVoice(t *testing.T) {
|
||||
t.Fatalf("CleanupVoiceForChannel calls = %v, want none on unarchive", hub.voiceCleanupIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// OC-0158: handlePatchChannel commits AdminUpdateChannel (which can set
|
||||
// archived=1) and only afterwards re-reads the row with
|
||||
// database.GetChannel(r.Context(), id). That read is still bound to the
|
||||
// admin's own request context, so a caller cancellation arriving right after
|
||||
// the commit (tab close, network blip) makes the re-read fail and the
|
||||
// handler return early — never calling hub.CleanupVoiceForChannel nor
|
||||
// hub.RefreshChannelVisibility, even though the archive already committed.
|
||||
// Live voice participants of an archived voice channel are then stuck with a
|
||||
// voice_states row, a VoiceTopic subscription and a LiveKit session in a room
|
||||
// nothing shows any more, and no sweep recovers them.
|
||||
//
|
||||
// This reproduces the race deterministically by cancelling the request
|
||||
// context from a hook that fires synchronously right after the
|
||||
// AdminUpdateChannel commit — exactly the window the repro describes a
|
||||
// browser abort landing in — instead of relying on wall-clock timing.
|
||||
func TestAdminAPI_PatchChannel_ArchiveSurvivesContextCancelAfterCommit(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", hub, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
token := createAdminUser(t, database)
|
||||
|
||||
chID, _ := database.AdminCreateChannel(context.Background(), "archive-cancel-race", "voice", "", "", 0)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
restore := admin.SetPatchChannelPostCommitHook(func() {
|
||||
cancel()
|
||||
})
|
||||
defer restore()
|
||||
|
||||
body, _ := json.Marshal(map[string]any{"archived": true})
|
||||
req := httptest.NewRequest(http.MethodPatch, "/channels/"+itoa(chID), bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (patch must survive a caller cancellation that arrives after the archive already committed); body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
ch, err := database.GetChannel(context.Background(), chID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChannel: %v", err)
|
||||
}
|
||||
if ch == nil || !ch.Archived {
|
||||
t.Fatalf("channel %d must be archived after a reported-successful patch: %+v", chID, ch)
|
||||
}
|
||||
|
||||
if len(hub.voiceCleanupIDs) != 1 || hub.voiceCleanupIDs[0] != chID {
|
||||
t.Errorf("CleanupVoiceForChannel calls = %v, want exactly [%d]", hub.voiceCleanupIDs, chID)
|
||||
}
|
||||
if len(hub.visibilityRefreshes) != 1 {
|
||||
t.Errorf("RefreshChannelVisibility calls = %d, want 1", len(hub.visibilityRefreshes))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -10,6 +13,14 @@ import (
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
// BroadcastRolesForTest exposes broadcastRoles for external tests. It builds
|
||||
// a bare *http.Request carrying ctx, since broadcastRoles's only use of its
|
||||
// *http.Request argument is r.Context().
|
||||
func BroadcastRolesForTest(ctx context.Context, database *db.DB, hub HubBroadcaster) {
|
||||
r := httptest.NewRequest(http.MethodPost, "/roles", nil).WithContext(ctx)
|
||||
broadcastRoles(r, database, hub)
|
||||
}
|
||||
|
||||
// CaptureSetupLimiter installs h so the next NewAdminAPI call reports the
|
||||
// *auth.RateLimiter it creates for the /setup endpoint. NewAdminAPI returns
|
||||
// only an http.Handler, so this is the only way tests can reach that limiter
|
||||
@@ -37,6 +48,17 @@ func SetSetupLimiterReapTiming(interval, maxWindow time.Duration) (restore func(
|
||||
// at a temp dir. Lives here so it stays out of the production binary.
|
||||
func SetBackupBaseDir(dir string) { backupBaseDir = dir }
|
||||
|
||||
// SetPatchChannelPostCommitHook installs h to run synchronously right after
|
||||
// handlePatchChannel's AdminUpdateChannel commit, before the post-commit
|
||||
// re-read and hub fan-out — the only way to deterministically land a caller
|
||||
// cancellation in that exact window (OC-0158) instead of racing wall-clock
|
||||
// timing.
|
||||
func SetPatchChannelPostCommitHook(h func()) (restore func()) {
|
||||
prev := patchChannelPostCommitHook
|
||||
patchChannelPostCommitHook = h
|
||||
return func() { patchChannelPostCommitHook = prev }
|
||||
}
|
||||
|
||||
// StubCopyBackup swaps the restore path's file-copy hook so tests can inject
|
||||
// mid-copy failures that pass the pre-copy integrity gate. CopyBackupForTest
|
||||
// is the real implementation, for stubs that only want to fail once.
|
||||
|
||||
@@ -196,6 +196,14 @@ func nsfwAuditSuffix(before, after bool) string {
|
||||
return " (unmarked NSFW)"
|
||||
}
|
||||
|
||||
// patchChannelPostCommitHook, when non-nil, runs synchronously right after
|
||||
// handlePatchChannel's AdminUpdateChannel commit, before the post-commit
|
||||
// re-read and hub fan-out. It exists so tests can deterministically simulate
|
||||
// a caller cancellation (browser tab close, network blip) landing in that
|
||||
// exact window — the race OC-0158 is about — instead of relying on
|
||||
// wall-clock timing to hit it.
|
||||
var patchChannelPostCommitHook func()
|
||||
|
||||
func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
existing := getAdminChannel(database, w, r)
|
||||
@@ -241,12 +249,27 @@ func handlePatchChannel(database *db.DB, hub HubBroadcaster) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// From here on the update has already committed. If the admin's
|
||||
// browser goes away in this window (tab close, navigation, network
|
||||
// blip), r.Context() cancels, and a GetChannel re-read that still
|
||||
// used it would fail with context.Canceled — 500ing while leaving
|
||||
// the commit (including a fresh archived=1) unbroadcast, its voice
|
||||
// eviction and visibility fan-out skipped, and every connected
|
||||
// client still showing the stale state until it reconnects
|
||||
// (OC-0158). Run the rest of the handler on an uncancellable tail,
|
||||
// matching handleDeleteChannel's delCtx (OC-0010).
|
||||
tail := context.WithoutCancel(r.Context())
|
||||
|
||||
if patchChannelPostCommitHook != nil {
|
||||
patchChannelPostCommitHook()
|
||||
}
|
||||
|
||||
actor := actorFromContext(r)
|
||||
slog.Info("channel updated", "actor_id", actor, "channel_id", id, "name", req.Name, "nsfw", req.NSFW)
|
||||
db.WriteAudit(context.WithoutCancel(r.Context()), database, actor, "channel_update", "channel", id,
|
||||
db.WriteAudit(tail, database, actor, "channel_update", "channel", id,
|
||||
fmt.Sprintf("updated #%s%s", req.Name, nsfwAuditSuffix(existing.NSFW, req.NSFW)))
|
||||
|
||||
updated, err := database.GetChannel(r.Context(), id)
|
||||
updated, err := database.GetChannel(tail, id)
|
||||
if err != nil || updated == nil {
|
||||
writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to fetch updated channel")
|
||||
return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
@@ -255,11 +256,17 @@ func invalidateUsers(permInvalidator PermissionInvalidator, userIDs []int64) {
|
||||
// broadcastRoles re-reads the role list and pushes it to every client. Re-read
|
||||
// rather than patched locally so the broadcast always reflects committed state,
|
||||
// including any concurrent change.
|
||||
//
|
||||
// Called after the mutation has already committed, so the caller's request
|
||||
// context may be canceled by the time this runs (client aborted, deadline
|
||||
// fired) -- context.WithoutCancel detaches the re-read from that, matching
|
||||
// broadcastEmojiSet in api/emoji_handler.go and broadcastDMOpen in
|
||||
// api/dm_handler.go.
|
||||
func broadcastRoles(r *http.Request, database *db.DB, hub HubBroadcaster) {
|
||||
if hub == nil || database == nil {
|
||||
return
|
||||
}
|
||||
list, err := database.ListRoles(r.Context())
|
||||
list, err := database.ListRoles(context.WithoutCancel(r.Context()))
|
||||
if err != nil {
|
||||
// The mutation already committed; clients converge on their next
|
||||
// reconnect rather than seeing a failed request.
|
||||
|
||||
@@ -405,6 +405,33 @@ func TestAdminAPI_ReorderRoles_NormalizesAndBroadcasts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── broadcast fan-out must survive request cancellation ────────────────────
|
||||
|
||||
// TestBroadcastRoles_SurvivesCanceledRequestContext pins OC-0170:
|
||||
// broadcastRoles re-reads the role list with r.Context() AFTER the mutation
|
||||
// (create/update/delete/reorder) has already committed. If the admin's
|
||||
// request is aborted (tab closed, deadline fired) in that window, the re-read
|
||||
// must not ride the same now-canceled context, or the roles_update broadcast
|
||||
// is silently skipped and every connected client keeps the stale role list.
|
||||
// This mirrors OC-0139's fix for broadcastEmojiSet in api/emoji_handler.go
|
||||
// and the analogous fix for broadcastDMOpen in api/dm_handler.go.
|
||||
func TestBroadcastRoles_SurvivesCanceledRequestContext(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
hub := &mockHub{}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // the request was already aborted by the time the commit lands
|
||||
|
||||
admin.BroadcastRolesForTest(ctx, database, hub)
|
||||
|
||||
if len(hub.rolesUpdates) != 1 {
|
||||
t.Fatalf("roles_update broadcasts = %d, want 1 (fan-out must survive a canceled request context)", len(hub.rolesUpdates))
|
||||
}
|
||||
if len(hub.rolesUpdates[0]) != 3 {
|
||||
t.Errorf("broadcast carried %d roles, want the seeded 3", len(hub.rolesUpdates[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPI_ReorderRoles_PartialListRefused(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler, hub, _, token := newRolesHandler(t, database)
|
||||
|
||||
@@ -11,15 +11,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/service"
|
||||
)
|
||||
|
||||
// setupSanitizer strips all HTML from user input during setup.
|
||||
var setupSanitizer = bluemonday.StrictPolicy()
|
||||
|
||||
// ownerRoleID is the role ID assigned to the first user (Owner).
|
||||
const ownerRoleID = 1
|
||||
|
||||
@@ -172,7 +169,15 @@ func setupPrecheck(w http.ResponseWriter, r *http.Request, limiter *auth.RateLim
|
||||
return req, "", false
|
||||
}
|
||||
|
||||
req.Username = strings.TrimSpace(setupSanitizer.Sanitize(req.Username))
|
||||
// Use the fixpoint sanitizer (service.SanitizeText), not a bare
|
||||
// bluemonday.StrictPolicy().Sanitize call: bluemonday's bare Sanitize HTML-escapes
|
||||
// survivors (' -> ', & -> &, " -> "), so a name like "O'Brien"
|
||||
// would be stored as "O'Brien" — different from what handleLogin
|
||||
// looks up later (which only trims), permanently locking the Owner out
|
||||
// of their own account. Mirrors the registration path (auth_handler.go)
|
||||
// and the profile-rename path (profile_handler.go), which canonicalize
|
||||
// usernames the same way. See service.SanitizeText's doc comment.
|
||||
req.Username = strings.TrimSpace(service.SanitizeText(req.Username))
|
||||
if req.Username == "" || req.Password == "" {
|
||||
writeErr(w, http.StatusBadRequest, "BAD_REQUEST", "username and password are required")
|
||||
return req, "", false
|
||||
|
||||
@@ -98,6 +98,44 @@ func TestSetup_CreatesOwner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetup_UsernameNotHTMLEscaped pins OC-0153: handleSetup must not persist
|
||||
// an HTML-escaped owner username. setupPrecheck canonicalized the username
|
||||
// with a bare bluemonday.StrictPolicy().Sanitize call, which HTML-escapes
|
||||
// survivors (' -> ', & -> &, " -> "), so a name like "O'Brien"
|
||||
// was stored as "O'Brien" — different from what the owner typed and from
|
||||
// what handleLogin looks up later (which only trims). That permanently locks
|
||||
// the Owner out of their own account. Mirrors the already-fixed sibling in
|
||||
// Server/api/auth_handler_test.go (TestRegister_UsernameNotHTMLEscaped).
|
||||
func TestSetup_UsernameNotHTMLEscaped(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]string{
|
||||
"username": "O'Brien",
|
||||
"password": "SecurePass123!",
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if resp.Username != "O'Brien" {
|
||||
t.Errorf("setup response username = %q, want %q (must not be HTML-escaped)", resp.Username, "O'Brien")
|
||||
}
|
||||
|
||||
// The username handleLogin will look up (raw, only trimmed) must match
|
||||
// what setup stored, or the owner is locked out of their own account.
|
||||
stored, err := database.GetUserByUsername(context.Background(), "O'Brien")
|
||||
if err != nil || stored == nil {
|
||||
t.Fatalf("GetUserByUsername(%q) = (%v, %v), want a match", "O'Brien", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetup_BlockedAfterFirstUser(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
handler := admin.NewAdminAPI(database, "1.0.0", nil, nil, nil, nil, nil, newTestModService(database), newTestRoleService(database))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/service"
|
||||
)
|
||||
|
||||
// ─── SetupOptions ────────────────────────────────────────────────────────────
|
||||
@@ -99,9 +100,17 @@ func validateWizard(wr *setupWizardRequest) error {
|
||||
|
||||
// wizardValidateIdentity checks and normalises the settings-table fields the
|
||||
// server reads live: the display name and the message of the day.
|
||||
//
|
||||
// It uses the fixpoint sanitizer (service.SanitizeText), not a bare
|
||||
// bluemonday.StrictPolicy().Sanitize call: bluemonday's bare Sanitize HTML-escapes
|
||||
// survivors (' -> ', & -> &, " -> "), which would store these
|
||||
// fields differently from how the admin Settings page's handlePatchSettings
|
||||
// stores the exact same keys (no sanitizer at all). See setup_handler.go's
|
||||
// identical treatment of the username field, and service.SanitizeText's doc
|
||||
// comment.
|
||||
func wizardValidateIdentity(wr *setupWizardRequest) error {
|
||||
if wr.ServerName != nil {
|
||||
name := strings.TrimSpace(setupSanitizer.Sanitize(*wr.ServerName))
|
||||
name := strings.TrimSpace(service.SanitizeText(*wr.ServerName))
|
||||
if name == "" {
|
||||
return fmt.Errorf("server_name cannot be empty")
|
||||
}
|
||||
@@ -111,7 +120,7 @@ func wizardValidateIdentity(wr *setupWizardRequest) error {
|
||||
*wr.ServerName = name
|
||||
}
|
||||
if wr.Motd != nil {
|
||||
motd := strings.TrimSpace(setupSanitizer.Sanitize(*wr.Motd))
|
||||
motd := strings.TrimSpace(service.SanitizeText(*wr.Motd))
|
||||
if len(motd) > maxMotdLen {
|
||||
return fmt.Errorf("motd must be at most %d characters", maxMotdLen)
|
||||
}
|
||||
|
||||
@@ -215,6 +215,41 @@ func TestSetupWizard_NoRestartWhenValuesMatchRunning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetupWizard_IdentityFieldsStoredRawNotEscaped pins OC-0173: the wizard
|
||||
// must store server_name/motd the same way handlePatchSettings does later
|
||||
// (raw survivors, not HTML-entity-escaped), so a name set at first run and
|
||||
// the identical name set afterwards through the admin Settings page produce
|
||||
// the same stored value. Before the fix, wizardValidateIdentity ran these
|
||||
// fields through the bare bluemonday sanitizer, which HTML-escapes
|
||||
// survivors (' -> ', " -> ", & -> &) — see service.SanitizeText's
|
||||
// doc comment, which the setup_handler.go username path already follows for
|
||||
// exactly this reason.
|
||||
func TestSetupWizard_IdentityFieldsStoredRawNotEscaped(t *testing.T) {
|
||||
database := openAdminTestDB(t)
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
restarted := make(chan string, 1)
|
||||
handler := wizardHandler(t, database, cfgPath, restarted)
|
||||
|
||||
rr := doRequest(t, handler, "POST", "/setup", "", map[string]any{
|
||||
"username": "owner",
|
||||
"password": "SecurePass123!",
|
||||
"wizard": map[string]any{
|
||||
"server_name": "Bob's Place",
|
||||
"motd": `Say "hi" & relax`,
|
||||
},
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("POST /setup = %d, want 201; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
if got, want := getSetting(t, database, "server_name"), "Bob's Place"; got != want {
|
||||
t.Errorf("server_name = %q, want %q (stored HTML-escaped instead of raw)", got, want)
|
||||
}
|
||||
if got, want := getSetting(t, database, "motd"), `Say "hi" & relax`; got != want {
|
||||
t.Errorf("motd = %q, want %q (stored HTML-escaped instead of raw)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupWizard_InvalidValuesRejectBeforeAccountCreation(t *testing.T) {
|
||||
cases := map[string]map[string]any{
|
||||
"port too low": {"port": 0},
|
||||
|
||||
@@ -1158,22 +1158,35 @@ async function clearPermOverride(){
|
||||
async function saveChannelPerms(){
|
||||
const pc=state.permChannel;if(!pc)return;
|
||||
try{
|
||||
/* Quick toggles first: same masks this panel has always written. */
|
||||
/* Quick toggles first: same masks this panel has always written. Track
|
||||
which roles this loop actually wrote — the override matrix below reads
|
||||
its radios from the pre-save snapshot, so if its target is one of
|
||||
these roles that snapshot is already stale and must not be trusted. */
|
||||
const touchedRoles=new Set();
|
||||
for(const role of pc.roles){
|
||||
if((role.permissions&ADMIN_BIT)!==0)continue;
|
||||
const box=document.getElementById('permRole'+role.role_id);
|
||||
if(!box)continue;
|
||||
const wasHidden=(role.deny&0x2)!==0;
|
||||
if(!box.checked)await api('PUT','/channels/'+pc.id+'/permissions/'+role.role_id,{allow:0,deny:DENY_PRIVATE});
|
||||
else if(wasHidden)await api('DELETE','/channels/'+pc.id+'/permissions/'+role.role_id);
|
||||
if(!box.checked){await api('PUT','/channels/'+pc.id+'/permissions/'+role.role_id,{allow:0,deny:DENY_PRIVATE});touchedRoles.add(role.role_id)}
|
||||
else if(wasHidden){await api('DELETE','/channels/'+pc.id+'/permissions/'+role.role_id);touchedRoles.add(role.role_id)}
|
||||
}
|
||||
/* Then the matrix, if a target is selected. An all-inherit row is a delete:
|
||||
storing (0,0) would leave a row that resolves to nothing. */
|
||||
const path=permTargetPath();
|
||||
if(path){
|
||||
const masks=collectOverrideMasks();
|
||||
if(masks.allow===0&&masks.deny===0)await api('DELETE',path);
|
||||
else await api('PUT',path,masks);
|
||||
/* Then the matrix, if a target is selected — unless the quick-toggle loop
|
||||
above just wrote that exact role's override row. Its radios reflect
|
||||
state from before that write, so collecting them now would silently
|
||||
undo the toggle (e.g. write back an all-inherit row that DELETEs what
|
||||
was just PUT). An all-inherit row is itself a delete: storing (0,0)
|
||||
would leave a row that resolves to nothing. */
|
||||
const sel=document.getElementById('permTarget');
|
||||
const targetVal=sel?sel.value:'';
|
||||
const targetIsTouchedRole=targetVal.charAt(0)==='r'&&touchedRoles.has(parseInt(targetVal.slice(2),10));
|
||||
if(!targetIsTouchedRole){
|
||||
const path=permTargetPath();
|
||||
if(path){
|
||||
const masks=collectOverrideMasks();
|
||||
if(masks.allow===0&&masks.deny===0)await api('DELETE',path);
|
||||
else await api('PUT',path,masks);
|
||||
}
|
||||
}
|
||||
closeModal();showToast('Channel permissions updated');renderContent();
|
||||
}catch(e){showToast(e.message,'error')}
|
||||
|
||||
@@ -12,16 +12,12 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
"github.com/owncord/server/service"
|
||||
)
|
||||
|
||||
// sanitizer strips all HTML from user-supplied strings before storage.
|
||||
var sanitizer = bluemonday.StrictPolicy()
|
||||
|
||||
// maxLoginUsernameLen bounds the username accepted by handleLogin, mirroring
|
||||
// auth.ValidateUsername's 32-rune cap on registered usernames. Enforced
|
||||
// before the value is ever used to build a RateLimiter map key — see the
|
||||
@@ -276,8 +272,25 @@ func registerReadRequest(w http.ResponseWriter, r *http.Request) (registerReques
|
||||
return req, false
|
||||
}
|
||||
|
||||
// F: use the fixpoint sanitizer (service.SanitizeText), not the bare
|
||||
// sanitizer.Sanitize below — Sanitize's output is always HTML-escaped
|
||||
// OC-0151: bound the raw field before it ever reaches the fixpoint
|
||||
// sanitizer below. sanitizeToFixpoint's cost is quadratic in input
|
||||
// length (nested HTML entities force roughly one extra pass per two
|
||||
// nesting levels), so an unauthenticated caller could otherwise pin a
|
||||
// core for minutes with one oversized username, all before
|
||||
// auth.ValidateUsername's 32-rune cap ever runs. This is a cheap
|
||||
// byte-length pre-check — *4 still admits any legitimate 32-rune UTF-8
|
||||
// username — mirroring sanitizeContent's raw-length bound in
|
||||
// service/message.go and loginReadRequest's username bound below.
|
||||
if len(req.Username) > maxLoginUsernameLen*4 {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "INVALID_INPUT",
|
||||
Message: "username is too long",
|
||||
})
|
||||
return req, false
|
||||
}
|
||||
|
||||
// F: use the fixpoint sanitizer (service.SanitizeText), not a bare
|
||||
// bluemonday.StrictPolicy().Sanitize call — Sanitize's output is always HTML-escaped
|
||||
// (' -> ', & -> &, " -> "), so a plain call here would store
|
||||
// a different string than what handleLogin looks up (which only
|
||||
// trims), permanently locking out any username containing one of
|
||||
|
||||
@@ -1215,6 +1215,47 @@ func TestLogin_OversizedUsernameRejectedBeforeRateLimiterKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// OC-0151: registerReadRequest ran the fixpoint sanitizer
|
||||
// (service.SanitizeText) over the raw username *before* auth.ValidateUsername
|
||||
// applies its 32-rune cap. The sanitizer loops sanitizePass to a fixpoint,
|
||||
// and nested HTML entities force roughly one extra pass per two nesting
|
||||
// levels, so the cost is quadratic in the attacker-controlled field length.
|
||||
// A 16 KB adversarial username measurably takes ~200ms to sanitize on this
|
||||
// tree (measured up to ~3.4s at 64 KB) — all of it spent before any bound on
|
||||
// the field is applied, and unauthenticated. The fix must reject an
|
||||
// oversized username on a cheap byte-length check *before* sanitizing, so
|
||||
// the rejection is near-instant regardless of payload size.
|
||||
func TestRegister_OversizedUsernameRejectedBeforeSanitizing(t *testing.T) {
|
||||
database := newAuthTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
router := buildAuthRouter(database, limiter)
|
||||
|
||||
// Adversarial nested-entity payload (16 KB) — see service.sanitizeToFixpoint's
|
||||
// doc comment for why this shape is quadratic to sanitize.
|
||||
hugeUsername := "&" + strings.Repeat("amp;", 4000) + "lt;"
|
||||
|
||||
start := time.Now()
|
||||
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
|
||||
"username": hugeUsername,
|
||||
"password": "securePass1",
|
||||
"invite_code": "whatever",
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("Register oversized username status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// A guard that runs before sanitizing rejects in well under a
|
||||
// millisecond; the pre-fix code spends ~200ms in sanitizeToFixpoint on
|
||||
// this payload before it ever reaches auth.ValidateUsername's length
|
||||
// check. 150ms gives generous margin over noise while still being far
|
||||
// below the unguarded cost.
|
||||
if elapsed > 150*time.Millisecond {
|
||||
t.Errorf("Register oversized username took %v, want well under 150ms (raw field must be bounded before sanitizing, not after)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rate limiting integration test ──────────────────────────────────────────
|
||||
|
||||
func TestRegister_RateLimit(t *testing.T) {
|
||||
|
||||
@@ -176,8 +176,21 @@ func handleUpdateProfile(svc *service.Services, broadcaster ProfileBroadcaster)
|
||||
return
|
||||
}
|
||||
|
||||
// Use the fixpoint sanitizer (service.SanitizeText), not the bare
|
||||
// sanitizer.Sanitize below — Sanitize's output is always
|
||||
// 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 '
|
||||
// and login (which never re-escapes) would look the account up
|
||||
// under a name that no longer matches. See service.SanitizeText's
|
||||
@@ -197,9 +210,14 @@ func handleUpdateProfile(svc *service.Services, broadcaster ProfileBroadcaster)
|
||||
return
|
||||
}
|
||||
|
||||
// Sanitize and validate avatar if provided.
|
||||
// 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 "&" and be persisted (and served) broken. Same reasoning as
|
||||
// the username path above.
|
||||
if req.Avatar != nil {
|
||||
trimmed := strings.TrimSpace(sanitizer.Sanitize(*req.Avatar))
|
||||
trimmed := strings.TrimSpace(service.SanitizeText(*req.Avatar))
|
||||
if err := validateAvatarURL(trimmed); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, errorResponse{
|
||||
Error: "INVALID_INPUT", Message: err.Error(),
|
||||
|
||||
@@ -164,6 +164,79 @@ func TestUpdateProfile_UsernameWithApostropheIsNotEscaped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// OC-0151: handleUpdateProfile is the same call in the same order as
|
||||
// registerReadRequest — service.SanitizeText (the fixpoint sanitizer) runs
|
||||
// on the raw username before auth.ValidateUsername's 32-rune cap. Since
|
||||
// sanitizeToFixpoint's cost is quadratic in input length, an authenticated
|
||||
// caller can still pin a core for hundreds of milliseconds (and much longer
|
||||
// at larger sizes) with one PATCH before any bound is applied. The fix must
|
||||
// reject an oversized username on a cheap byte-length check before
|
||||
// sanitizing, so the rejection is near-instant regardless of payload size.
|
||||
func TestUpdateProfile_OversizedUsernameRejectedBeforeSanitizing(t *testing.T) {
|
||||
database := newAuthTestDB(t)
|
||||
router := buildProfileRouter(database)
|
||||
token := profileCreateToken(t, database, "patchvictim", 4)
|
||||
|
||||
// Adversarial nested-entity payload (16 KB) — see service.sanitizeToFixpoint's
|
||||
// doc comment for why this shape is quadratic to sanitize.
|
||||
hugeUsername := "&" + strings.Repeat("amp;", 4000) + "lt;"
|
||||
|
||||
start := time.Now()
|
||||
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
||||
"username": hugeUsername,
|
||||
})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("UpdateProfile oversized username status = %d, want 400; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// A guard that runs before sanitizing rejects in well under a
|
||||
// millisecond; the pre-fix code spends ~200ms in sanitizeToFixpoint on
|
||||
// this payload before it ever reaches auth.ValidateUsername's length
|
||||
// check. 150ms gives generous margin over noise while still being far
|
||||
// below the unguarded cost.
|
||||
if elapsed > 150*time.Millisecond {
|
||||
t.Errorf("UpdateProfile oversized username took %v, want well under 150ms (raw field must be bounded before sanitizing, not after)", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// HTML-escaped, so a legitimate avatar URL with more than one query
|
||||
// parameter gets its "&" separators rewritten to "&" and is persisted
|
||||
// (and later served to every client) as a broken URL.
|
||||
func TestUpdateProfile_AvatarQueryStringIsNotEscaped(t *testing.T) {
|
||||
database := newAuthTestDB(t)
|
||||
router := buildProfileRouter(database)
|
||||
token := profileCreateToken(t, database, "avatarqsuser", 4)
|
||||
|
||||
const avatarURL = "https://www.gravatar.com/avatar/abc?s=256&d=identicon"
|
||||
|
||||
rr := patchJSON(t, router, "/api/v1/users/me", token, map[string]string{
|
||||
"username": "avatarqsuser",
|
||||
"avatar": avatarURL,
|
||||
})
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if resp["avatar"] != avatarURL {
|
||||
t.Errorf("avatar = %v, want %q (must not be HTML-escaped)", resp["avatar"], avatarURL)
|
||||
}
|
||||
|
||||
u, err := database.GetUserByUsername(context.Background(), "avatarqsuser")
|
||||
if err != nil || u == nil {
|
||||
t.Fatalf("GetUserByUsername: %v, %v", u, err)
|
||||
}
|
||||
if u.Avatar == nil || *u.Avatar != avatarURL {
|
||||
t.Errorf("stored avatar = %v, want %q (must not be HTML-escaped)", u.Avatar, avatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_UsernameTaken(t *testing.T) {
|
||||
database := newAuthTestDB(t)
|
||||
router := buildProfileRouter(database)
|
||||
|
||||
@@ -112,9 +112,17 @@ func (d *DB) PluginKVDelete(ctx context.Context, pluginID int64, key string) err
|
||||
}
|
||||
|
||||
func (d *DB) PluginKVScan(ctx context.Context, pluginID int64, prefix string, limit int) (map[string][]byte, error) {
|
||||
// A BINARY prefix comparison, not LIKE: LIKE treats '_'/'%' in prefix as
|
||||
// wildcards and is ASCII-case-insensitive by default, which disagrees
|
||||
// with the exact `key = ?` match used by PluginKVGet/Set/Delete on this
|
||||
// same table. `key >= ?` keeps the (plugin_id, key) primary-key index
|
||||
// usable for the seek; substr(key, 1, length(?)) = ? compares under the
|
||||
// column's default BINARY collation, so no wildcards and no case-folding.
|
||||
rows, err := d.reader.QueryContext(ctx,
|
||||
`SELECT key, value FROM plugin_kv WHERE plugin_id = ? AND key LIKE ? ORDER BY key LIMIT ?`,
|
||||
pluginID, prefix+"%", limit,
|
||||
`SELECT key, value FROM plugin_kv
|
||||
WHERE plugin_id = ? AND key >= ? AND substr(key, 1, length(?)) = ?
|
||||
ORDER BY key LIMIT ?`,
|
||||
pluginID, prefix, prefix, prefix, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PluginKVScan: %w", err)
|
||||
|
||||
@@ -321,6 +321,54 @@ func TestPluginKVScan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginKVScan_IsBinaryPrefixMatch pins that PluginKVScan is an exact,
|
||||
// case-sensitive BINARY prefix match — matching the exact-match semantics of
|
||||
// PluginKVGet/Set/Delete on the same table — rather than a SQL LIKE pattern
|
||||
// match, where '_' and '%' are wildcards and matching is ASCII
|
||||
// case-insensitive.
|
||||
func TestPluginKVScan_IsBinaryPrefixMatch(t *testing.T) {
|
||||
database := newMigratedTestDB(t)
|
||||
ctx := context.Background()
|
||||
id := installTestPlugin(t, database, "hello")
|
||||
|
||||
for k, v := range map[string]string{
|
||||
"cfg_a": "underscore-match",
|
||||
"cfgXa": "not-a-prefix-match",
|
||||
"Key1": "capital-key",
|
||||
"key1": "lowercase-key",
|
||||
} {
|
||||
if err := database.PluginKVSet(ctx, id, k, []byte(v)); err != nil {
|
||||
t.Fatalf("PluginKVSet(%s): %v", k, err)
|
||||
}
|
||||
}
|
||||
|
||||
// '_' in the prefix must be a literal underscore, not a LIKE
|
||||
// single-character wildcard, so "cfgXa" must not be returned.
|
||||
underscoreScan, err := database.PluginKVScan(ctx, id, "cfg_", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("PluginKVScan cfg_: %v", err)
|
||||
}
|
||||
if _, ok := underscoreScan["cfgXa"]; ok {
|
||||
t.Errorf("PluginKVScan(%q) = %v; '_' matched any character like a LIKE wildcard, but PluginKVGet treats \"cfg_a\" and \"cfgXa\" as distinct keys", "cfg_", underscoreScan)
|
||||
}
|
||||
if len(underscoreScan) != 1 || !bytes.Equal(underscoreScan["cfg_a"], []byte("underscore-match")) {
|
||||
t.Errorf("PluginKVScan(%q) = %v, want exactly {cfg_a: underscore-match}", "cfg_", underscoreScan)
|
||||
}
|
||||
|
||||
// Matching must be case-sensitive (BINARY), matching key = ? on the same
|
||||
// table, so scanning "Key" must not return "key1".
|
||||
caseScan, err := database.PluginKVScan(ctx, id, "Key", 100)
|
||||
if err != nil {
|
||||
t.Fatalf("PluginKVScan Key: %v", err)
|
||||
}
|
||||
if _, ok := caseScan["key1"]; ok {
|
||||
t.Errorf("PluginKVScan(%q) = %v; LIKE's ASCII case-insensitivity matched \"key1\", but PluginKVGet/Delete treat \"Key1\" and \"key1\" as distinct keys", "Key", caseScan)
|
||||
}
|
||||
if len(caseScan) != 1 || !bytes.Equal(caseScan["Key1"], []byte("capital-key")) {
|
||||
t.Errorf("PluginKVScan(%q) = %v, want exactly {Key1: capital-key}", "Key", caseScan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPlugin_ReinstallReturnsCorrectID_AfterOtherWrites(t *testing.T) {
|
||||
database := openMigratedMemory(t)
|
||||
ctx := context.Background()
|
||||
|
||||
+24
-9
@@ -18,6 +18,7 @@ package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -30,8 +31,15 @@ type foundPlugin struct {
|
||||
}
|
||||
|
||||
// scanPluginDirectory walks dir non-recursively and parses plugin.json from
|
||||
// every immediate subdirectory. Returns on the first error encountered;
|
||||
// partial results are not returned alongside errors.
|
||||
// every immediate subdirectory. A per-plugin failure (malformed manifest,
|
||||
// missing or symlinked entrypoint, a stray symlink anywhere in that plugin's
|
||||
// tree) is recorded and that one subdirectory is skipped — it does not stop
|
||||
// the scan. The returned error is non-nil whenever at least one subdirectory
|
||||
// was skipped, joining every such failure, but `found` still holds every
|
||||
// plugin that scanned cleanly. Callers that need the scan to be all-or-
|
||||
// nothing should check the returned error before using `found`; LoadAll
|
||||
// deliberately does not, so one bad plugin directory cannot disable every
|
||||
// other plugin (OC-0165).
|
||||
func scanPluginDirectory(dir string) ([]foundPlugin, error) {
|
||||
if dir == "" {
|
||||
return nil, nil
|
||||
@@ -45,6 +53,7 @@ func scanPluginDirectory(dir string) ([]foundPlugin, error) {
|
||||
return nil, err
|
||||
}
|
||||
var found []foundPlugin
|
||||
var scanErr error
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
@@ -54,7 +63,8 @@ func scanPluginDirectory(dir string) ([]foundPlugin, error) {
|
||||
// Prefer plugin.toml (wazero build) over plugin.json.
|
||||
manifest, ok, tomlErr := tryLoadPluginTOML(pluginDir)
|
||||
if tomlErr != nil {
|
||||
return nil, fmt.Errorf("plugin %q: %w", e.Name(), tomlErr)
|
||||
scanErr = errors.Join(scanErr, fmt.Errorf("plugin %q: %w", e.Name(), tomlErr))
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
// Fall back to plugin.json.
|
||||
@@ -64,12 +74,14 @@ func scanPluginDirectory(dir string) ([]foundPlugin, error) {
|
||||
if os.IsNotExist(rdErr) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("plugin %q: read plugin.json: %w", e.Name(), rdErr)
|
||||
scanErr = errors.Join(scanErr, fmt.Errorf("plugin %q: read plugin.json: %w", e.Name(), rdErr))
|
||||
continue
|
||||
}
|
||||
var parseErr error
|
||||
manifest, parseErr = ParseManifest(raw)
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("plugin %q: %w", e.Name(), parseErr)
|
||||
scanErr = errors.Join(scanErr, fmt.Errorf("plugin %q: %w", e.Name(), parseErr))
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Reject any symlinks anywhere in the plugin directory tree. The asset
|
||||
@@ -80,13 +92,16 @@ func scanPluginDirectory(dir string) ([]foundPlugin, error) {
|
||||
// check below so a symlink is detected instead of followed, even
|
||||
// when its target is a valid .wasm file.
|
||||
if err := rejectSymlinksUnder(pluginDir); err != nil {
|
||||
return nil, fmt.Errorf("plugin %q: %w", e.Name(), err)
|
||||
scanErr = errors.Join(scanErr, fmt.Errorf("plugin %q: %w", e.Name(), err))
|
||||
continue
|
||||
}
|
||||
wasmPath := filepath.Join(pluginDir, manifest.Entrypoint)
|
||||
if info, statErr := os.Lstat(wasmPath); statErr != nil {
|
||||
return nil, fmt.Errorf("plugin %q: missing entrypoint %s: %w", e.Name(), manifest.Entrypoint, statErr)
|
||||
scanErr = errors.Join(scanErr, fmt.Errorf("plugin %q: missing entrypoint %s: %w", e.Name(), manifest.Entrypoint, statErr))
|
||||
continue
|
||||
} else if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("plugin %q: entrypoint %s is a symlink", e.Name(), manifest.Entrypoint)
|
||||
scanErr = errors.Join(scanErr, fmt.Errorf("plugin %q: entrypoint %s is a symlink", e.Name(), manifest.Entrypoint))
|
||||
continue
|
||||
}
|
||||
found = append(found, foundPlugin{
|
||||
Manifest: manifest,
|
||||
@@ -94,7 +109,7 @@ func scanPluginDirectory(dir string) ([]foundPlugin, error) {
|
||||
WASMPath: wasmPath,
|
||||
})
|
||||
}
|
||||
return found, nil
|
||||
return found, scanErr
|
||||
}
|
||||
|
||||
// rejectSymlinksUnder walks root and returns an error if any entry is a
|
||||
|
||||
@@ -65,6 +65,47 @@ func TestRejectSymlinksUnderFindsNestedSymlink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// OC-0165: a single malformed plugin directory must not blank out every
|
||||
// other, otherwise-valid plugin in the scan. scanPluginDirectory should skip
|
||||
// the bad directory (recording its error) and still return the good one.
|
||||
func TestScanPluginDirectory_SkipsBadPluginButReturnsGood(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
// Good plugin: valid plugin.json + matching .wasm entrypoint.
|
||||
goodDir := filepath.Join(root, "hello")
|
||||
if err := os.MkdirAll(goodDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
goodManifest := []byte(`{"name":"hello","version":"1.0.0","entrypoint":"hello.wasm"}`)
|
||||
if err := os.WriteFile(filepath.Join(goodDir, "plugin.json"), goodManifest, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(goodDir, "hello.wasm"), []byte("\x00asm"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Broken plugin: malformed JSON (trailing comma).
|
||||
brokenDir := filepath.Join(root, "broken")
|
||||
if err := os.MkdirAll(brokenDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
brokenManifest := []byte(`{"name":"broken","version":"1.0.0","entrypoint":"broken.wasm",}`)
|
||||
if err := os.WriteFile(filepath.Join(brokenDir, "plugin.json"), brokenManifest, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
found, err := scanPluginDirectory(root)
|
||||
if err == nil {
|
||||
t.Fatal("expected scanPluginDirectory to report an error for the broken plugin")
|
||||
}
|
||||
if len(found) != 1 {
|
||||
t.Fatalf("scanPluginDirectory returned %d plugins, want 1 (the good one survived alongside the reported error); got %+v", len(found), found)
|
||||
}
|
||||
if found[0].Manifest.Name != "hello" {
|
||||
t.Fatalf("scanPluginDirectory returned plugin %q, want \"hello\"", found[0].Manifest.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanPluginDirectoryRejectsSymlinkEntrypoint(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("symlink creation requires elevated privileges on Windows")
|
||||
|
||||
@@ -168,9 +168,14 @@ func (r *Registry) LoadAll(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// scanPluginDirectory reports a non-nil error whenever at least one
|
||||
// plugin subdirectory failed to parse, but it still returns every
|
||||
// plugin that scanned cleanly in `manifests`. Log-and-continue here
|
||||
// rather than aborting: one malformed plugin directory must not take
|
||||
// every other, otherwise-valid plugin down with it (OC-0165).
|
||||
manifests, err := scanPluginDirectory(r.cfg.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("plugin: scan %q: %w", r.cfg.Directory, err)
|
||||
slog.Warn("plugin: some plugin directories failed to scan and were skipped", "dir", r.cfg.Directory, "err", err)
|
||||
}
|
||||
for _, found := range manifests {
|
||||
if err := r.installFromDisk(ctx, found); err != nil {
|
||||
|
||||
@@ -114,6 +114,45 @@ func TestRegistry_LoadAll_RegistersDiscoveredPlugins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// OC-0165: one malformed plugin directory must not blank the whole registry.
|
||||
// LoadAll must still install and activate every good plugin alongside a
|
||||
// broken one, matching installFromDisk's own per-plugin-failure policy a few
|
||||
// lines below (a bad plugin there just gets `slog.Warn` + `continue`).
|
||||
func TestRegistry_LoadAll_InstallsGoodPluginsDespiteOneBadDirectory(t *testing.T) {
|
||||
r, store, dir := newRegistryWithDir(t)
|
||||
ctx := context.Background()
|
||||
writePluginDir(t, dir, "alpha", simpleManifest("alpha"))
|
||||
|
||||
// "broken" has a plugin.json that fails to parse — malformed JSON.
|
||||
brokenDir := filepath.Join(dir, "broken")
|
||||
if err := os.MkdirAll(brokenDir, 0o750); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", brokenDir, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(brokenDir, "plugin.json"), []byte(`{"name":"broken",}`), 0o600); err != nil {
|
||||
t.Fatalf("write plugin.json: %v", err)
|
||||
}
|
||||
|
||||
if err := r.LoadAll(ctx); err != nil {
|
||||
t.Fatalf("LoadAll: %v — a malformed plugin directory must not fail the whole load", err)
|
||||
}
|
||||
|
||||
list := r.List()
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("List() has %d entries after LoadAll, want 1 (alpha installed despite broken's failure); got %+v", len(list), list)
|
||||
}
|
||||
if list[0].Manifest.Name != "alpha" {
|
||||
t.Errorf("List()[0].Manifest.Name = %q, want \"alpha\"", list[0].Manifest.Name)
|
||||
}
|
||||
|
||||
rows, err := store.ListPlugins(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPlugins: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Errorf("store has %d rows, want 1 — the good plugin must still be persisted", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_LoadAll_RemovesStaleStagingDirs(t *testing.T) {
|
||||
r, _, dir := newRegistryWithDir(t)
|
||||
|
||||
|
||||
@@ -84,7 +84,18 @@ func platformInit(cfg Config) (any, func(context.Context) error, error) {
|
||||
if memMB <= 0 {
|
||||
memMB = 64 // default 64 MiB per plugin runtime
|
||||
}
|
||||
memPages := uint32(memMB) * 1024 * 1024 / wazeroPageBytes
|
||||
// Compute the byte count in 64-bit before dividing down to pages: doing
|
||||
// the multiplication in uint32 wraps at 4 GiB, so a configured
|
||||
// max_memory_mb at or above 4096 would silently truncate (or zero out)
|
||||
// the limit actually installed. wazero's own ceiling is 65536 pages
|
||||
// (4 GiB, wasm32's addressable maximum; WithMemoryLimitPages panics
|
||||
// above it), so clamp to that after computing in 64-bit.
|
||||
const wazeroMaxPages = 65536
|
||||
pages := uint64(memMB) * 1024 * 1024 / wazeroPageBytes
|
||||
if pages > wazeroMaxPages {
|
||||
pages = wazeroMaxPages
|
||||
}
|
||||
memPages := uint32(pages)
|
||||
|
||||
rt := wazero.NewRuntimeWithConfig(ctx,
|
||||
wazero.NewRuntimeConfig().
|
||||
|
||||
@@ -26,6 +26,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/tetratelabs/wazero"
|
||||
)
|
||||
|
||||
// addWASM is the bytes of a minimal (module (func (export "add") ... )).
|
||||
@@ -499,3 +501,40 @@ func TestWazeroDeactivateClosesCompiledModule(t *testing.T) {
|
||||
t.Error("deactivate leaked the CompiledModule — re-activation cycles retain every compile")
|
||||
}
|
||||
}
|
||||
|
||||
// memoryWASM is a minimal module containing only a memory section declaring
|
||||
// `(memory 1)` — a single required page, no export needed. wazero validates
|
||||
// a module's declared memory against the runtime's configured page limit at
|
||||
// CompileModule time (internal/wasm.Memory.Validate), so this is enough to
|
||||
// observe the effective page limit platformInit installed.
|
||||
var memoryWASM = []byte{
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic, version
|
||||
0x05, 0x03, 0x01, 0x00, 0x01, // memory section: 1 memory, min-only, min=1 page
|
||||
}
|
||||
|
||||
// TestPlatformInitPageCountDoesNotOverflowUint32 pins OC-0183:
|
||||
// `uint32(memMB) * 1024 * 1024 / wazeroPageBytes` computed the byte count in
|
||||
// uint32 before dividing, so it wraps at 4 GiB. A MaxMemoryMB of exactly 4096
|
||||
// (4 GiB) wraps the byte count to 0, so WithMemoryLimitPages(0) is installed
|
||||
// and every plugin whose WASM declares a memory section fails to compile —
|
||||
// even though 4096 MiB is a legitimate, in-range request (wazero's own
|
||||
// ceiling is 65536 pages = 4 GiB, i.e. exactly this value is allowed).
|
||||
func TestPlatformInitPageCountDoesNotOverflowUint32(t *testing.T) {
|
||||
platform, closeFn, err := platformInit(Config{MaxMemoryMB: 4096})
|
||||
if err != nil {
|
||||
t.Fatalf("platformInit: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = closeFn(context.Background()) })
|
||||
|
||||
rt, ok := platform.(wazero.Runtime)
|
||||
if !ok || rt == nil {
|
||||
t.Fatal("platformInit did not return a usable wazero.Runtime")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if _, err := rt.CompileModule(ctx, memoryWASM); err != nil {
|
||||
t.Fatalf("CompileModule with MaxMemoryMB=4096 should succeed (4096 MiB = 65536 pages, "+
|
||||
"wazero's own ceiling) but got: %v — the byte-count math overflowed uint32 and wrapped "+
|
||||
"the effective memory limit to (near) zero pages", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,23 @@ func (h *Hub) EmitEvents(ctx context.Context, events []Event) {
|
||||
// Low priority: typing indicators are ephemeral.
|
||||
h.broadcastExcludeLow(e.ChannelID(), e.ExcludeUserID(), e.Payload())
|
||||
}
|
||||
case PresenceSelfEvent:
|
||||
// Normal priority, NOT the UserTargetedEvent default below (which
|
||||
// PresenceSelfEvent also satisfies — this case must stay ordered
|
||||
// before it so the type switch picks this one). Every other
|
||||
// source of this same user's own presence — the visible
|
||||
// presence_update path (PresenceEvent -> BroadcastToAll) and the
|
||||
// connect/disconnect coalescer's private half
|
||||
// (BroadcastPresence -> h.SendToUser) — already shares the
|
||||
// normal-priority queue. Routing this one through
|
||||
// h.SendToUserHigh instead split one user's own presence across
|
||||
// two per-client FIFOs with different drain order: writePump
|
||||
// always drains high strictly before normal, so a newer
|
||||
// invisible self-frame on high could be delivered before an
|
||||
// older visible-status frame still sitting on normal, leaving
|
||||
// the owner's own client on a stale status — the same hazard
|
||||
// OC-0003/OC-0214 fixed for the "others" half of presence.
|
||||
h.SendToUser(e.TargetUserID(), e.Payload())
|
||||
case UserTargetedEvent:
|
||||
// High priority: targeted events (DM opens, mentions).
|
||||
// dm_channel_open is unsequenced and targeted, so replay can never
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package ws
|
||||
|
||||
// emit_presence_self_priority_test.go — regression test for OC-0166: the
|
||||
// private half of an invisible user's presence (PresenceSelfEvent) satisfies
|
||||
// UserTargetedEvent, so EmitEvents routed it through h.SendToUserHigh onto the
|
||||
// HIGH-priority queue, while every other source of that same user's own
|
||||
// presence — the visible presence_update path (PresenceEvent -> BroadcastToAll)
|
||||
// and the connect/disconnect coalescer's private half
|
||||
// (BroadcastPresence -> h.SendToUser) — shares the NORMAL-priority queue.
|
||||
// writePump always drains high strictly before normal (serve_pumps.go), so a
|
||||
// newer invisible self-frame queued on high can reach the socket ahead of an
|
||||
// older visible-status frame still sitting on normal, leaving the owner's own
|
||||
// client showing a stale status. This is the same split-FIFO hazard OC-0003 /
|
||||
// OC-0214 fixed for the "others" half of presence; this pins the self half.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestEmitEvents_PresenceSelfEvent_UsesNormalPriorityQueue pins the fix: a
|
||||
// PresenceSelfEvent, routed through EmitEvents, must land on the owner's
|
||||
// normal-priority queue — the same FIFO every other source of that user's own
|
||||
// presence uses — never the high-priority queue.
|
||||
//
|
||||
// Before the fix, PresenceSelfEvent fell through to the UserTargetedEvent
|
||||
// case in emit.go and was sent via h.SendToUserHigh, so this test observes
|
||||
// the frame on c.sendHigh instead of c.send, and fails.
|
||||
func TestEmitEvents_PresenceSelfEvent_UsesNormalPriorityQueue(t *testing.T) {
|
||||
h := newEmitTestHub()
|
||||
|
||||
// Built directly (not via the emit_test.go helpers) so send and sendHigh
|
||||
// are DISTINCT channels — the shared-channel helpers in export_test.go
|
||||
// unify them "for test observability" and would mask exactly the
|
||||
// queue-split this test needs to detect.
|
||||
owner := &Client{
|
||||
hub: h,
|
||||
ctx: context.Background(),
|
||||
userID: 1,
|
||||
send: make(chan []byte, 8),
|
||||
sendHigh: make(chan []byte, 8),
|
||||
sendLow: make(chan []byte, 8),
|
||||
}
|
||||
h.clients[1] = owner
|
||||
|
||||
payload := []byte(`{"type":"presence","user_id":1,"status":"invisible"}`)
|
||||
h.EmitEvents(context.Background(), []Event{
|
||||
PresenceSelfEvent{targetUserID: 1, payload: payload},
|
||||
})
|
||||
|
||||
normalMsgs := drainChan(owner.send, 200*time.Millisecond)
|
||||
highMsgs := drainChan(owner.sendHigh, 50*time.Millisecond)
|
||||
|
||||
if len(normalMsgs) != 1 {
|
||||
t.Errorf("expected the private half of an invisible presence change on "+
|
||||
"the owner's normal-priority queue (same FIFO as the visible "+
|
||||
"presence_update path and the connect/disconnect coalescer's "+
|
||||
"private half), got %d normal messages, %d high messages",
|
||||
len(normalMsgs), len(highMsgs))
|
||||
}
|
||||
if len(highMsgs) != 0 {
|
||||
t.Errorf("invisible presence's private half must not go out on the "+
|
||||
"high-priority queue: writePump drains high strictly before "+
|
||||
"normal, so a newer self-frame queued there can be delivered "+
|
||||
"before an older visible-status frame still sitting on normal, "+
|
||||
"leaving the owner's own view stale; got %d high messages",
|
||||
len(highMsgs))
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/permissions"
|
||||
"github.com/owncord/server/ws"
|
||||
)
|
||||
@@ -169,3 +170,42 @@ func TestApplySetChannelID_TransientLookupError_KeepsFocus(t *testing.T) {
|
||||
t.Errorf("client channelID = %d, want %d (focus must survive a transient lookup error)", got, ch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplySetChannelID_ArchivedChannel_Unwinds pins OC-0175: the applier's
|
||||
// re-validation mirrors HandleChannelFocus's DM-participant and
|
||||
// READ_MESSAGES legs but never looks at ch.Archived, even though
|
||||
// HandleChannelFocus itself refuses an archived channel (service/channel.go,
|
||||
// OC-0070) and archiving is exactly the kind of visibility change the
|
||||
// revalidation exists to catch (OC-0024). A channel archived in the window
|
||||
// between the admission gate and the Subscribe call must not leave the
|
||||
// socket subscribed and focused forever, matching the deleted-channel case
|
||||
// above.
|
||||
func TestApplySetChannelID_ArchivedChannel_Unwinds(t *testing.T) {
|
||||
hub, database := newHandlerHub(t)
|
||||
user := seedMemberUser(t, database, "focus-archived-user")
|
||||
ch := seedTestChannel(t, database, "focus-archived-chan")
|
||||
|
||||
send := make(chan []byte, 16)
|
||||
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
||||
hub.Register(c)
|
||||
waitRegistered(t, hub, c)
|
||||
|
||||
// Simulate the admin's archive having already committed before the
|
||||
// applier runs: the admission gate (HandleChannelFocus) ran and passed
|
||||
// before this, exactly as in the OC-0024 revoke-race test above.
|
||||
if err := database.AdminUpdateChannel(context.Background(), ch, db.ChannelUpdate{
|
||||
Name: "focus-archived-chan",
|
||||
Archived: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("AdminUpdateChannel: %v", err)
|
||||
}
|
||||
|
||||
hub.ApplySetChannelIDForTest(c, ch)
|
||||
|
||||
if hub.SubscribedToChannelTopicForTest(c, ch) {
|
||||
t.Error("client must not stay subscribed to an archived channel's topic")
|
||||
}
|
||||
if got := ws.ClientChannelIDForTest(c); got != 0 {
|
||||
t.Errorf("client channelID = %d, want 0 after focusing a channel archived mid-race", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ func (h *Hub) applySetChannelID(c *Client, newChID int64) {
|
||||
if ok, dmErr := h.db.IsDMParticipant(c.ctx, c.userID, newChID); dmErr != nil || ok {
|
||||
return
|
||||
}
|
||||
} else if ch != nil && hasChannelAccess(c.ctx, h.db, h.permChecker, h.perms, c.userID, newChID, permissions.ReadMessages) {
|
||||
} else if ch != nil && !ch.Archived && hasChannelAccess(c.ctx, h.db, h.permChecker, h.perms, c.userID, newChID, permissions.ReadMessages) {
|
||||
return
|
||||
}
|
||||
h.pubsub.Unsubscribe(c, ChannelTopic(newChID))
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package ws
|
||||
|
||||
// oc_0172_voice_join_getchannelvoicestates_error_test.go — regression test
|
||||
// for finding OC-0172.
|
||||
//
|
||||
// voiceJoinComplete had already subscribed the joiner to the voice topic,
|
||||
// re-elected the key holder, and broadcast the joiner's own voice_state to
|
||||
// every client that can see the channel by the time it reads back the
|
||||
// channel's existing participants via GetChannelVoiceStates. That read is
|
||||
// also the ONLY place the server ever relays an existing participant's
|
||||
// stored ECDH public key (voice_e2ee_announce) to a joiner. When the read
|
||||
// failed, the old code just `return`ed: no error frame, no rollback of the
|
||||
// voice_states row it had already committed, no compensating voice_leave for
|
||||
// the voice_state it had already broadcast, and the client's in-memory
|
||||
// voiceChID stayed set even though the join never finished. The joiner was
|
||||
// left half-joined and silent, guaranteed to fail the E2EE key exchange with
|
||||
// whoever was already in the channel and time out ~15s later with no
|
||||
// explanation.
|
||||
//
|
||||
// This reuses voiceJoinPostTokenRaceHook (already test-only plumbing for
|
||||
// OC-0008) to fault-inject exactly the failure this finding describes: it
|
||||
// fires after the token round trip completes and before voiceJoinComplete's
|
||||
// GetChannelVoiceStates call, so everything up to and including the joiner's
|
||||
// own voice_state broadcast has already happened by the time the DB read
|
||||
// that this finding is about fails.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/config"
|
||||
"github.com/owncord/server/db"
|
||||
)
|
||||
|
||||
// TestVoiceJoin_GetChannelVoiceStatesError_RollsBackAndNotifiesClient pins
|
||||
// OC-0172: a GetChannelVoiceStates failure inside voiceJoinComplete must not
|
||||
// leave the joiner silently half-joined. It must roll back the DB row it
|
||||
// already committed and tell the client the join failed, the same way every
|
||||
// other post-commit failure in this handler already does (BUG-088's
|
||||
// rollbackVoiceJoin, OC-0008's token-supersession guard).
|
||||
func TestVoiceJoin_GetChannelVoiceStatesError_RollsBackAndNotifiesClient(t *testing.T) {
|
||||
database := newHarvestVoiceDB(t)
|
||||
uid := seedHarvestVoiceUser(t, database, "join-0172-victim")
|
||||
chID := mustCreateVoiceChannel(t, database, "voice-join-0172")
|
||||
|
||||
lk, err := NewLiveKitClient(&config.VoiceConfig{
|
||||
LiveKitAPIKey: "test-api-key-0172",
|
||||
LiveKitAPISecret: "test-api-secret-0172-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-0172-victim"}
|
||||
h.mu.Lock()
|
||||
h.clients[uid] = c
|
||||
h.mu.Unlock()
|
||||
|
||||
// Fault-inject the GetChannelVoiceStates call inside voiceJoinComplete.
|
||||
// This hook fires after GenerateToken succeeds and before the token is
|
||||
// handed to the client — i.e. strictly before voiceJoinComplete runs, so
|
||||
// by the time GetChannelVoiceStates executes, the `users` table it joins
|
||||
// against is gone and it returns an error. Nothing between the hook and
|
||||
// that call touches the DB (subscribe, updateKeyHolder, and the joiner's
|
||||
// own voice_state broadcast are all in-memory), so this does not perturb
|
||||
// any earlier step.
|
||||
//
|
||||
// Renaming (not dropping) `users` is deliberate: with foreign keys
|
||||
// enabled, SQLite's DROP TABLE performs an implicit cascading DELETE
|
||||
// through any FK referencing the dropped table before removing it (see
|
||||
// https://www.sqlite.org/lang_droptable.html), which would delete the
|
||||
// joiner's own voice_states row as a side effect of the fault injection
|
||||
// itself — masking whether the handler's own rollback logic is what
|
||||
// cleaned it up. A rename breaks the same JOIN without touching any row.
|
||||
var hookRan bool
|
||||
voiceJoinPostTokenRaceHook = func(client *Client) {
|
||||
hookRan = true
|
||||
if _, err := database.ExecContext(context.Background(), `ALTER TABLE users RENAME TO users_bak_0172`); 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")
|
||||
}
|
||||
|
||||
msgs := drainChan(send, 200*time.Millisecond)
|
||||
|
||||
var gotError bool
|
||||
for _, m := range msgs {
|
||||
var env struct {
|
||||
Type string `json:"type"`
|
||||
Payload struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
if err := json.Unmarshal(m, &env); err != nil {
|
||||
continue
|
||||
}
|
||||
if env.Type == MsgTypeError {
|
||||
gotError = true
|
||||
if env.Payload.Code != ErrCodeInternal {
|
||||
t.Errorf("error frame code = %q, want %q", env.Payload.Code, ErrCodeInternal)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !gotError {
|
||||
t.Error("client received no error frame after GetChannelVoiceStates failed mid-join — the join silently half-completed with no explanation")
|
||||
}
|
||||
|
||||
// The client's in-memory voice state must be cleared, not left pointing
|
||||
// at a join the server gave up on partway through.
|
||||
if gotCh := c.getVoiceChID(); gotCh != 0 {
|
||||
t.Errorf("client voiceChID = %d after GetChannelVoiceStates failed mid-join, want 0 (rolled back)", gotCh)
|
||||
}
|
||||
|
||||
// The voice_states row committed earlier in the handler must not survive
|
||||
// a join that never finished. Query without the `users` JOIN so the
|
||||
// dropped table (a fault-injection artifact, not part of the finding)
|
||||
// does not itself break this check.
|
||||
var count int
|
||||
if err := database.QueryRowContext(context.Background(),
|
||||
`SELECT COUNT(*) FROM voice_states WHERE user_id = ?`, uid).Scan(&count); err != nil {
|
||||
t.Fatalf("count voice_states: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("voice_states row for user %d still present after a join that failed mid-completion, want it rolled back", uid)
|
||||
}
|
||||
}
|
||||
+23
-6
@@ -32,6 +32,24 @@ const (
|
||||
maxColdReplay = 5000
|
||||
)
|
||||
|
||||
// handshakeWrite writes one handshake-phase message (auth_ok, ready, or a
|
||||
// replay event) under writeTimeout, instead of the bare ctx every caller here
|
||||
// otherwise has on hand.
|
||||
//
|
||||
// Every handshake write runs against ctx = r.Context() from ServeWS.
|
||||
// websocket.Accept hijacks the connection, which stops net/http's own
|
||||
// mechanism for cancelling that context on client disconnect, so without this
|
||||
// wrapper ctx is never cancelled while the handler is blocked inside
|
||||
// conn.Write — a peer that stops reading (or whose receive window closes)
|
||||
// pins the write, the handler goroutine, and the socket forever (OC-0152).
|
||||
// writePumpWrite (serve_pumps.go) already bounds its writes the same way;
|
||||
// this brings the handshake writes in serve.go up to the same guarantee.
|
||||
func handshakeWrite(ctx context.Context, conn *websocket.Conn, msg []byte) error {
|
||||
wCtx, cancel := context.WithTimeout(ctx, writeTimeout)
|
||||
defer cancel()
|
||||
return conn.Write(wCtx, websocket.MessageText, msg)
|
||||
}
|
||||
|
||||
// ServeWS upgrades an HTTP connection to WebSocket, performs in-band auth,
|
||||
// then drives the client's read/write loops.
|
||||
// Do not wrap with AuthMiddleware — WS does its own auth.
|
||||
@@ -510,14 +528,14 @@ func (h *Hub) reconnectWriteReplay(
|
||||
// is included in the payload so the client can attribute reconnect
|
||||
// behaviour without separate metric scraping.
|
||||
slog.Info("ws sending auth_ok (reconnect)", "user_id", c.userID, "username", c.user.Username, "role", c.roleName, "replay_source", replaySource)
|
||||
if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, replaySource)); err != nil {
|
||||
if err := handshakeWrite(ctx, conn, h.buildAuthOK(ctx, c.user, c.roleName, replaySource)); err != nil {
|
||||
slog.Warn("ws: failed to send auth_ok (reconnect)", "user_id", c.userID, "err", err)
|
||||
h.unregisterFailedHandshake(ctx, c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
||||
return false
|
||||
}
|
||||
for _, evt := range events {
|
||||
if err := conn.Write(ctx, websocket.MessageText, evt); err != nil {
|
||||
if err := handshakeWrite(ctx, conn, evt); err != nil {
|
||||
slog.Warn("ws: failed to send replay event", "user_id", c.userID, "err", err)
|
||||
h.unregisterFailedHandshake(ctx, c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
||||
@@ -745,7 +763,7 @@ func (h *Hub) handleFreshConnect(
|
||||
|
||||
// Fresh connection or replay fallback: full auth_ok + ready flow.
|
||||
slog.Info("ws sending auth_ok", "user_id", c.userID, "username", c.user.Username, "role", c.roleName)
|
||||
if err := conn.Write(ctx, websocket.MessageText, h.buildAuthOK(ctx, c.user, c.roleName, "none")); err != nil {
|
||||
if err := handshakeWrite(ctx, conn, h.buildAuthOK(ctx, c.user, c.roleName, "none")); err != nil {
|
||||
slog.Warn("ws: failed to send auth_ok", "user_id", c.userID, "err", err)
|
||||
h.unregisterFailedHandshake(ctx, c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
||||
@@ -753,7 +771,7 @@ func (h *Hub) handleFreshConnect(
|
||||
}
|
||||
if ready, readyErr := h.buildReady(ctx, database, c.userID, userRole); readyErr == nil {
|
||||
slog.Info("ws sending ready payload", "user_id", c.userID, "payload_bytes", len(ready))
|
||||
if err := conn.Write(ctx, websocket.MessageText, ready); err != nil {
|
||||
if err := handshakeWrite(ctx, conn, ready); err != nil {
|
||||
slog.Warn("ws: failed to send ready payload", "user_id", c.userID, "err", err)
|
||||
h.unregisterFailedHandshake(ctx, c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "handshake failed")
|
||||
@@ -761,8 +779,7 @@ func (h *Hub) handleFreshConnect(
|
||||
}
|
||||
} else {
|
||||
slog.Error("buildReady failed", "user_id", c.userID, "err", readyErr)
|
||||
_ = conn.Write(ctx, websocket.MessageText,
|
||||
buildErrorMsg(ErrCodeInternal, "failed to build ready payload"))
|
||||
_ = handshakeWrite(ctx, conn, buildErrorMsg(ErrCodeInternal, "failed to build ready payload"))
|
||||
h.unregisterFailedHandshake(ctx, c)
|
||||
_ = conn.Close(websocket.StatusInternalError, "failed to build ready payload")
|
||||
return readyErr
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package ws_test
|
||||
|
||||
// serve_handshake_write_deadline_test.go pins OC-0152: every handshake write
|
||||
// in serve.go (auth_ok / ready / reconnect replay) is issued with the bare
|
||||
// r.Context() instead of a bounded write context. websocket.Accept hijacks the
|
||||
// connection, which stops net/http's own read loop from ever cancelling that
|
||||
// context, so coder/websocket's Conn.Write blocks on the underlying socket
|
||||
// write forever once a stalled peer's receive window and the server's send
|
||||
// buffer fill — pinning the handler goroutine, the client's slot in the hub,
|
||||
// and the file descriptor for good.
|
||||
//
|
||||
// The test shrinks both sides' TCP socket buffers to the kernel minimum (so
|
||||
// a bounded amount of unread traffic is enough to make the write block, no
|
||||
// megabyte-scale burst required) and seeds enough members that the ready
|
||||
// payload alone comfortably exceeds that minimum. It then dials, completes
|
||||
// auth, and never reads another byte. Registration happens before the
|
||||
// handshake writes (serve.go), so hub.ClientCount() drops back to 0 only once
|
||||
// the blocked write returns — with a deadline, that happens once writeTimeout
|
||||
// elapses; without one, it never happens and the poll loop below times out.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/ws"
|
||||
)
|
||||
|
||||
// tinySendBufListener wraps a net.Listener and shrinks SO_SNDBUF on every
|
||||
// accepted connection to the kernel minimum, so the server's handshake writes
|
||||
// cannot buffer their way past a peer that stops reading.
|
||||
type tinySendBufListener struct {
|
||||
net.Listener
|
||||
}
|
||||
|
||||
func (l *tinySendBufListener) Accept() (net.Conn, error) {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tc, ok := c.(*net.TCPConn); ok {
|
||||
_ = tc.SetWriteBuffer(1) // kernel clamps this up to its own floor
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// TestServeWS_HandshakeWrite_TimesOutOnStalledPeer pins OC-0152. It fails
|
||||
// against the pre-fix code and passes once every handshake write is wrapped
|
||||
// in a bounded write context.
|
||||
//
|
||||
// The two buffer-shrinking tricks below (tinySendBufListener +
|
||||
// SetReadBuffer) don't produce a truly infinite block in this sandbox's
|
||||
// network stack — TCP window mechanics still let bytes trickle through
|
||||
// eventually even though nothing ever calls Read. What they reliably produce
|
||||
// is a large, measurable slowdown: a ~500KB ready payload measured well
|
||||
// north of 30s to complete against the pre-fix code in this environment,
|
||||
// against a fixed writeTimeout of 10s. So instead of asserting "never
|
||||
// returns", the test asserts the behavior the fix is actually supposed to
|
||||
// guarantee: the handshake resolves (success or failure) within
|
||||
// writeTimeout-plus-margin. That holds post-fix regardless of payload size
|
||||
// (the AfterFunc-driven close fires at the deadline no matter how much data
|
||||
// is still queued) and fails pre-fix for any payload large enough to still
|
||||
// be in flight at that point — which the member count below is sized well
|
||||
// past, for margin.
|
||||
func TestServeWS_HandshakeWrite_TimesOutOnStalledPeer(t *testing.T) {
|
||||
database := openServeTestDB(t)
|
||||
limiter := auth.NewRateLimiter()
|
||||
hub := ws.NewHub(database, limiter, nil)
|
||||
go hub.Run()
|
||||
defer hub.Stop()
|
||||
|
||||
// Seed enough members that the ready payload takes long enough to
|
||||
// trickle through the shrunk buffers below that it is still in flight
|
||||
// well past writeTimeout — see the function doc for why this needs to
|
||||
// be "slow enough to still be running at the deadline", not "infinite".
|
||||
for i := range 4000 {
|
||||
if _, err := database.CreateUser(context.Background(), fmt.Sprintf("bulk-member-%d", i), "hash", 1); err != nil {
|
||||
t.Fatalf("CreateUser(bulk-member-%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Seed the connecting user's own session.
|
||||
userID, err := database.CreateUser(context.Background(), "stalled-peer-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)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("net.Listen: %v", err)
|
||||
}
|
||||
srv := httptest.NewUnstartedServer(handler)
|
||||
_ = srv.Listener.Close()
|
||||
srv.Listener = &tinySendBufListener{Listener: ln}
|
||||
srv.Start()
|
||||
defer srv.Close()
|
||||
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
// Custom HTTP client that shrinks SO_RCVBUF on the dial connection to the
|
||||
// kernel minimum, so this "peer" advertises a tiny receive window once it
|
||||
// stops draining it.
|
||||
dialer := &net.Dialer{}
|
||||
httpClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
c, dialErr := dialer.DialContext(ctx, network, addr)
|
||||
if dialErr != nil {
|
||||
return nil, dialErr
|
||||
}
|
||||
if tc, ok := c.(*net.TCPConn); ok {
|
||||
_ = tc.SetReadBuffer(1) // kernel clamps this up to its own floor
|
||||
}
|
||||
return c, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
dialCtx, dialCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer dialCancel()
|
||||
conn, resp, err := websocket.Dial(dialCtx, wsURL, &websocket.DialOptions{HTTPClient: httpClient})
|
||||
if resp != nil && resp.Body != nil {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("websocket.Dial: %v", err)
|
||||
}
|
||||
defer func() { _ = conn.Close(websocket.StatusInternalError, "test done") }()
|
||||
|
||||
authMsg := map[string]any{
|
||||
"type": "auth",
|
||||
"payload": map[string]string{"token": token},
|
||||
}
|
||||
raw, _ := json.Marshal(authMsg)
|
||||
authCtx, authCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer authCancel()
|
||||
if err := conn.Write(authCtx, websocket.MessageText, raw); err != nil {
|
||||
t.Fatalf("write auth: %v", err)
|
||||
}
|
||||
|
||||
// Read auth_ok (small — writes/reads quickly regardless of the buffer
|
||||
// shrinking below) so that by the time we start the stall phase,
|
||||
// registration has DEFINITELY already happened (registerNow runs before
|
||||
// any handshake write — serve.go). Without this, polling ClientCount()
|
||||
// immediately after sending auth races the server's own goroutine
|
||||
// scheduling: an early poll can observe ClientCount()==0 simply because
|
||||
// registration hasn't happened *yet*, producing a false pass unrelated to
|
||||
// OC-0152 on both the buggy and the fixed code.
|
||||
readCtx, readCancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer readCancel()
|
||||
_, authOKRaw, err := conn.Read(readCtx)
|
||||
if err != nil {
|
||||
t.Fatalf("read auth_ok: %v", err)
|
||||
}
|
||||
var authOK struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(authOKRaw, &authOK); err != nil || authOK.Type != "auth_ok" {
|
||||
t.Fatalf("expected auth_ok, got %q (unmarshal err %v)", authOKRaw, err)
|
||||
}
|
||||
if hub.ClientCount() != 1 {
|
||||
t.Fatalf("ClientCount = %d right after auth_ok, want 1 (registration happens before this write)", hub.ClientCount())
|
||||
}
|
||||
|
||||
// From here on the test deliberately never reads another frame — this is
|
||||
// the stalled peer. The next handshake write is the ready payload; a
|
||||
// ClientCount of 1 below just means that write is still in flight, and
|
||||
// can only fall back to 0 once it returns (success or, post-fix, timeout)
|
||||
// and the failed-handshake teardown runs.
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if hub.ClientCount() == 0 {
|
||||
return // handshake write returned (timed out) and cleaned up — fixed.
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
t.Fatalf("OC-0152: handshake write to a stalled peer did not resolve within %v of writeTimeout margin — "+
|
||||
"ClientCount is still %d, meaning the write is bound to the bare request "+
|
||||
"context (never cancelled while the handler blocks in it) instead of a "+
|
||||
"writeTimeout-bounded one", 15*time.Second, hub.ClientCount())
|
||||
}
|
||||
@@ -477,9 +477,22 @@ func (h *Hub) voiceJoinComplete(ctx context.Context, c *Client, ch *db.Channel,
|
||||
h.broadcastVoiceEvent(ctx, channelID, buildVoiceState(*state))
|
||||
|
||||
// Send existing channel voice states to the joiner.
|
||||
//
|
||||
// OC-0172: this read is the ONLY place the server ever relays an existing
|
||||
// participant's stored ECDH public key (voice_e2ee_announce) to a joiner
|
||||
// — mid-call peers never counter-announce, they only answer an offer. A
|
||||
// swallowed error here used to just `return`, leaving the joiner's own
|
||||
// voice_state already broadcast to everyone (above) but the joiner
|
||||
// itself blind to who else is in the channel and unable to complete the
|
||||
// E2EE key exchange: it times out ~15s later with no explanation. Treat
|
||||
// this the same as every other post-commit failure in this handler
|
||||
// (rollbackVoiceJoin + an error frame), broadcasting the compensating
|
||||
// voice_leave for the voice_state that already went out.
|
||||
existing, err := h.db.GetChannelVoiceStates(ctx, channelID)
|
||||
if err != nil {
|
||||
slog.Error("ws handleVoiceJoin GetChannelVoiceStates", "err", err)
|
||||
h.rollbackVoiceJoin(ctx, c, channelID, state.JoinedAt, true)
|
||||
c.sendMsg(buildErrorMsg(ErrCodeInternal, "failed to join voice channel"))
|
||||
return
|
||||
}
|
||||
for _, vs := range existing {
|
||||
|
||||
Reference in New Issue
Block a user