* docs(plans): phased remediation plan for the 2026-08-19 audit Executes the audit's §8 MUST-fix verdict and §9.1 fix order: one phase per finding group, statuses updated in place as phases land. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * test(client): give the renderWindow-breaker test its own timeout (audit F-5) 30 synchronous 100-row jsdom rebuilds can exceed vitest's default 5s on a loaded runner; the test timed out once under CI-like load and passes in isolation, so it now carries an explicit 20s budget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * docs: fix the ten wrong reference-doc statements from audit 2026-08-19 (B-01..B-10) schema.md: migrations 030/031 documented, attachments ON DELETE SET NULL (matching 030's rebuild), index inventory rewritten from cumulative migration state, writer/reader pool split described, default-roles table made a consistent post-migration snapshot, dbgen preamble updated. protocol.md: DM chat events documented as sequenced/ring-buffered/replayable (they are), plugin_broadcast seq flipped to Yes, retry_after claim removed (no WS error carries it), the five enforced-but-documented-as-None rate limits added (channel_focus, mark_read, call_decline, chat_command, ping), E2EE announce/offer budgets corrected incl. the per-target inner cap, BAD_PAYLOAD and NOT_KEY_HOLDER added to the error table, ready voice_states/ roles field lists completed, member_join top-level status documented. api.md: diagnostics endpoint is ADMINISTRATOR-only (H-8) with a per-IP limiter and host:port livekit_url, error-code table now matches emitted codes (INTERNAL_ERROR, STORAGE_ERROR 507; oversize upload is 400), body-cap exemptions listed, identity_public_key documented on PATCH /users/me, plugin endpoints' plain-text errors + X-Plugin-Runtime header documented, /health 503 degraded state documented, metrics/LiveKit CIDR keys named, updates/apply restart-conflict 409s added. Also folds in the audit's D-04/D-05 comment and plan-header staleness fixes (buildReady comment, e2e spec-count comments, logctx stray word, three plan status headers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(server): log the five silently-discarded persistence errors (audit F-3/F-4/D-16) Lockout Upsert/Delete/Cleanup failures (auth/ratelimit.go), the H-6 session-cap eviction failure in CreateSession (db/auth_queries.go), and the channel_focus read-state write failure (service/channel.go) all discarded their errors with no trace — a brute-force lockout could silently fail to survive a restart. In-memory behavior is unchanged (warn-and-continue); the lockout write paths are pinned by tests mirroring OC-0061's load-path test. The session-cap and read-state sites are log-only additions on seams the existing suites already exercise on the success path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(dm): blocking a user evicts them from the pair's live 1:1 DM voice call (audit F-1) The block gate ran only at voice_join and voluntary voice_token_refresh, so a blocked user already in the shared 1:1 DM call kept their session indefinitely — the same guard-asymmetry family as A-2026-08-03. handleBlockUser now severs the call through the dmVoiceEvictor capability handleCloseDM already exercises, using a new find-only FindDMChannelIDBetween lookup (sqlc-generated; mirrors GetOrCreateDMChannel's is_group=0 clause so group DM calls stay exempt, matching requireDMNotBlocked). Pinned by three handler tests: shared-DM eviction, no-DM no-op, group-only no-op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(ws): close the role-reassignment/handshake race (audit F-2) A role reassignment landing mid-handshake was invisible for the socket's whole life: both handshake paths resolved permissions from the auth-time c.user snapshot, revokeUnreadableChannels early-returns for a user not yet in h.clients, and its Unsubscribe no-ops on the pubsub identity guard once a reconnect replaced the client. Three coordinated fixes: (1) refreshUserSnapshot re-reads the user row (and role name) in reconnectPrecheck and handleFreshConnect, fail-closed; (2) the resume-fallback path re-reads the role once more after registerNow and runs the revocation pass when it moved, so the reassignment-vs-registration orderings meet in the middle; (3) revokeUnreadableChannels re-resolves the live client immediately before acting, mirroring RefreshChannelVisibility. Pinned by four tests driving real WS handshakes through the existing race hooks plus a new pre-register/pre-act hook pair; ws suite green under the default and deadlock builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * refactor(client): remove the inert replay-dedup machinery (audit F-6) The server writes auth_ok before the replay burst, so replayDedup — created on socket-open and cleared when auth_ok is processed — could never be active for a real replayed frame, and the dispatcher's isReplaying() unread gates never fired. Their no-op behavior is the correct behavior (a buffer/db resume has no ready payload, so replayed frames must count as unread), so the machinery, the gates, and the misleading comments are removed rather than repaired. The pinning tests injected replay frames in an order a spec-compliant server never produces; they are replaced by a test pinning the real contract (frames after auth_ok are dispatched verbatim; duplicate handling belongs to the stores). Client suite green: 5036/5036. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * docs(plans): mark remediation phases 1-6 done Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ * fix(ws): nolint the context-less revoke call golangci-lint flags revokeUnreadableChannels takes no context by design (admin HubBroadcaster interface); annotate the one call site inside a ctx-taking function, matching the RefreshChannelVisibility precedent. golangci-lint v2.11.3: 0 issues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HtkxwdqE4pUv82GQPRsTeQ --------- Co-authored-by: Claude <noreply@anthropic.com>
50 KiB
Discord feature parity — gap analysis and plan
Status (verified 2026-08-04): Shipped — phases 1–6 complete. Phase 1's table below was written as a gap list and never re-marked; all six rows have since shipped (block/unblock UI
SidebarMemberSection.ts:177-186; topics inreadyviachannelPayloadFrom; role colors from the server list with seeded-name fallbackformatting.ts:158-175; profile popup mounted fromMemberList.ts; temp bansBanDurationHoursinPATCH /admin/api/users/{id}; archived channels hidden by the unified predicatepermissions/checker.go:116-121). Named leftovers remain open and are listed in-line: role hoist/mentionable flags +@RoleNamementions (Phase 5), categories as real entities (Phase 5), and the §"still-dead code" cleanup list (voice_speakers,voice_config.bitrate, macOS PTT stub — thesoundstable was dropped by migration 029 on 2026-08-04, A-2026-07-13).
Status: phase 6 complete (2026-08-01)
This is a depth audit of features OwnCord already has, compared against what Discord's version of the same feature can do (free tier, with Nitro notes where relevant). Wholly absent features (threads, forums, polls, soundboard, …) are listed at the end for reference but are out of scope here — the focus is finishing the features we have.
Where OwnCord already meets or beats Nitro
- Uploads: 100 MB default (free Discord: 10 MB, Nitro Basic: 50 MB, Nitro: 500 MB) — and
max_upload_bytesis operator-configurable. - Message length: 4000 runes, equal to Nitro's limit (free Discord: 2000).
- Streaming: source-quality preset and up to 120 fps, above Nitro's 1080p60/4K60.
- Client themes: full custom CSS-var theming with import/export — a paid perk on Discord.
- E2EE voice with TOFU identity verification — Discord has no equivalent at any tier.
Phase 1 — quick wins (this branch)
Features where one side is already built and the other was never finished.
| # | Item | What exists | What's missing |
|---|---|---|---|
| 1 | Block/unblock UI | Full server enforcement (user_blocks, DM create + send checks), GET /blocks client call |
Client never calls PUT/DELETE /api/v1/blocks/{userId}; no menu items |
| 2 | Channel topics in client | channels.topic column, admin-panel editing |
Not in WS ready payload; chat header never renders it; client edit modal is name-only |
| 3 | Role colors | roles.color stored and shipped in ready |
Client hardcodes a switch on 4 role names (formatting.ts) |
| 4 | Profile popup | UserProfilePopup.ts built and unit-tested |
Never mounted; member click opens only the admin context menu |
| 5 | Temp bans | users.ban_expires, BanUser(..., expires), IsEffectivelyBanned all honor expiry |
Every caller passes nil; no API field, no UI |
| 6 | Archived channels | channels.archived stored, settable in admin panel |
No read path filters on it — archived channels appear everywhere |
Phase 2 — moderation depth
- DONE (2026-07-31) — Honest kick semantics. There is no membership model, so
DELETE /admin/api/users/{id}/sessionscannot remove anyone; it revokes the target's sessions and they can sign straight back in. Rather than invent a membership table, the user-facing action is renamed to what it does: the desktop member-list menu item is now Force Logout (confirm "Log them out?", pending "Logging out...", toast "Forced {name} to log out"), and the admin panel's row button, modal and toast say Force Logout too, with the modal spelling out that the user can sign back in. The endpoint, theKICK_MEMBERSbit and theonKick/adminKickMembercall sites are unchanged. - DONE (2026-07-31) — Enforce the decorative permission bits. The
/admin/apiperimeter now admits any role holding a bit ofpermissions.AdminPerimeterinstead of requiringADMINISTRATOR, and each route group re-checks its own bit: channels + channel overrides →MANAGE_CHANNELS, audit log →VIEW_AUDIT_LOG, settings →MANAGE_SERVER, force-logout →KICK_MEMBERS; ban/unban (BAN_MEMBERS) and role assignment (MANAGE_ROLES) are authorized insideModerationService. Stats/users/GET /mestay perimeter-level; backups, updates, API tokens, plugins and the log stream are unchanged.GET /admin/api/mereports the caller's mask so the admin panel hides tabs and row actions it cannot use, and the desktop member-list context menu gates Kick/Ban/Change Role on the bits from thereadyrole list instead of on the role name.MUTE_MEMBERSadmits to the perimeter but still has no route behind it (see voice moderation below). - DONE (2026-07-31) — Hierarchy checks beyond ban/unban:
ModerationService.ChangeUserRolerequires the actor to strictly outrank the target and refuses to assign a role positioned at or above the actor's own, closing the "any admin can promote anyone to Owner" hole.ModerationService.ForceLogoutenforces the same outranks rule. - DONE (2026-07-31) — Voice moderation on
MUTE_MEMBERS(the bit is now live):voice_mod_mute,voice_mod_deafen,voice_mod_moveandvoice_mod_kick, each requiring the bit plus a strict role-position outrank of the target, rate limited 5/sec and audit-logged.voice_statesgainedserver_muted/server_deafened, which thevoice_statebroadcast now carries; a server mute is also applied to the target's published audio track via the LiveKit RoomService, and the target's ownvoice_mute/voice_deafenunmute attempts are refused withSERVER_MUTED/SERVER_DEAFENED. Move and disconnect run the hub's voice-leave routine for the target and then send themvoice_moved(client re-joins the destination through the ordinary join path) orvoice_disconnected. The desktop client's voice-user context menu grows a moderation section gated on the bit, renders a distinct server-muted icon, and disables the widget's mute/deafen buttons with a reason while server muted. - DONE (2026-07-31) — Bulk message delete.
POST /api/v1/channels/{id}/messages/purgetakes{limit: 1-100, before?}and soft-deletes the newest matching messages, gated onREAD_MESSAGES|MANAGE_MESSAGESfor that channel (per-channel overrides apply, DMs rejected — a DM has no MANAGE_MESSAGES gate to answer to). Deletion is the same soft delete a single delete performs, so tombstones andreply_totargets survive; already-deleted rows are skipped and the select+update run in one writer transaction. Onemessage_purgeaudit entry per call carries the count, and the fan-out is a single newchat_bulk_deleted {channel_id, ids}server->client message instead of Nchat_deletedevents. The desktop channel context menu grows a "Purge Messages…" item — gated on the actor'sMANAGE_MESSAGESbit and hidden on voice channels — opening an inline 1-100 count prompt with a confirm step; the dispatcher marks every id in the broadcast as deleted.
Phase 3 — mentions done right
The largest single messaging gap: @word was regex-highlighted with no
resolution, no notification and no badge, and read_states.mention_count was a
dead column. The server is now the authority on what a mention is — the client
highlights and badges from the resolved fields rather than re-parsing content.
- DONE (2026-07-31) — Server-side mention resolution and storage.
MessageService.resolveMentionsparses@tokens out of sanitized content with a word-boundary rule (mentionTokenRe) that refuses address-shaped text:mail@exampleand@@namenever match, and@bob@example.comis rejected whole rather than half-matched. Tokens are lowercased, deduplicated, ordered by first appearance and resolved case-insensitively againstusers.username(UNIQUE COLLATE NOCASE), with a second spelling that drops trailing./-so "@bob." resolves to bob when nobody is literally named "bob.". A token matching no username resolves to nothing and stays plain text. Two caps bound the work one send can cause: at most 60 distinct tokens are looked up (maxMentionCandidates) and at most 20 resolve (maxMentionsPerMessage). Resolved IDs land in the newmessage_mentionstable (migration022, PK(message_id, mentioned_user_id)plusidx_message_mentions_userfor the per-user direction), written in the same writer transaction as the message row and rewritten wholesale on edit. Resolution failures are logged and degrade to "no mentions" — a message is never rejected because its mention lookup failed.mentionsandmentions_everyonenow ride on thechat_messageandchat_editedbroadcasts, onGET /channels/{id}/messages, on pinned-message responses and on FTS search results;mentionsis always present and empty rather than null.buildChatMessagetook achatMessageArgsstruct in the process — the positional list had outgrown a readable call site. - DONE (2026-07-31) —
read_states.mention_countis live.applyMentionCountsraises it on message insert for every mentioned user who can actually read the channel — the role walk applies channel overrides, and DMs skip it entirely since participation is membership, not permissions. The author is always excluded, and users who have blocked the author are dropped (fail-closed: aListBlockersOferror skips the whole increment, because a badge from a blocked user is worse than no badge). Edits deliberately never increment: only the original send can raise a badge, which is the simplest rule that makes double-counting a re-added mention impossible.channel_focusresets the count to 0 via theUpdateReadStateupsert, and thereadypayload shipsmention_countper channel. BecauseGetChannelUnreadCountscoverstext/announcementchannels, a DM mention badge is raised live by the dispatcher but starts at 0 on reconnect — DM unreads are surfaced separately in the DM sidebar. - DONE (2026-07-31) — Client rendering, badges and notifications.
@lib/mentionsis the single source of truth shared by the renderer, the badge path and the notification gate, so all three agree on what counts as a mention; its regex mirrors the server's, and the server'smentions/mentions_everyonedecide the outcome whenever present (the local token parse only stands in for servers predating the fields). Resolved mentions render as a highlighted.mentionspan, with.mention-selfwhen the mention is the current user; an unresolvable token renders as plain text. In the channel sidebar a red.mention-badgeoutranks the plain unread badge — only one shows, and it counts mentions rather than messages. Desktop notifications retitle to "{user} mentioned you in #{channel}", and "Suppress @everyone" now means exactly that: it drops only a notification the@everyone/@herealone caused, so a message that also names you still notifies, and an@everyonethe sender lacked the bit for was never a mention to suppress. No OS dock/taskbar count badge was added — the existing taskbar flash is the only OS-level signal; a real badge count needs a Tauri-side API and is left for a later pass. - DONE (2026-07-31) —
@everyone/@herebehind a permission, plus composer autocomplete. NewMENTION_EVERYONEbit (21,0x200000); migration022grants it to the seeded Owner/Admin/Moderator roles, moving the Moderator mask from0x000FFFFFto0x002FFFFF. Without the bit the token carries no mention semantics at all — no highlight, no badge, no notification — and DM channels have no@everyonesemantics since there is no permission surface to answer to.@herenarrows the fan-out to readers whose status is notoffline;@everyonereaches every reader. The composer opens an inline member picker on@(MentionAutocomplete, max 10 rows) whose active-token rule mirrors the server's, so it never offers a completion for text a send would not resolve;@everyone/@hereappear as rows only for users who hold the bit. - DONE (2026-07-31) — Clickable
#channellinks.#nametokens in message content resolve case-insensitively against the channel store (DM channels excluded — they have no user-visible#name) and render as links; unresolvable tokens stay plain text. Navigation funnels through the new@lib/channel-navigation.navigateToChannel, now the single entry point shared by the sidebar item and#channellinks, so every affordance clears the same unread and mention badges. Role mentions remain out of scope by design — they need role management, which is phase 5.
Phase 4 — markdown and message polish
- DONE (2026-08-01) — Full markdown rendering, client-side. The content parser grew a real tokenizer (
message-list/markdown.ts): one left-to-right scan with recursive descent into matched delimiter pairs, which is what makes nesting (**bold *and italic***), backslash escaping and "markdown is dead inside code" fall out of a single rule set instead of a pile of regexes fighting over overlaps. Inline: bold, italic (*/_, with a word-boundary rule sosnake_case_namesstay literal), underline, strikethrough and spoilers; blocks (line-start only):>quotes that merge contiguous lines,>>>for the rest of the message,#–###headings that require the space,-/*/1.lists with one level of nesting. Masked links accept absolutehttp(s)only —javascript:,data:and relatives render as their literal source — and are excluded fromextractUrls, so hiding an address does not get it previewed back. Code fences take a language tag that renders as a label and drives a hand-rolled highlighter (syntax-highlight.ts: comments/strings/numbers/keywords for js/ts, go, python, rust, json, bash, css, html, plain fallback) — no highlighting dependency was added. Spoilers are per-spanrole="button"elements witharia-pressed, and the revealing click is swallowed so a link underneath cannot open with it. Rendering stays a strict DOM builder: noinnerHTMLanywhere, everyhrefthroughisSafeUrl. Composer: Ctrl+B/I/U wrap (and unwrap) the selection, stopping propagation so Ctrl+U formats while typing and still uploads elsewhere. - DONE (2026-08-01) — Message navigation: fetch-around, reply jumps, permalinks. Server gained
GET /api/v1/channels/{id}/messages/around/{messageId}?limit=50— the same read gate as history (READ_MESSAGES / DM membership), the window split half-and-half around the centre and returned oldest-first, withhas_more_before/has_more_afterderived by over-fetching one row per side rather than two extra count queries. A centre that is soft-deleted is a 404, not an empty window: history omits deleted rows, so there is nothing to centre on. The three duplicated read-permission blocks inMessageServicecollapsed into onerequireChannelRead, and the three copies of limit parsing in the handlers into oneparseLimitParam. Client-side every jump affordance — search hit, pinned entry, the quoted reply bar, a permalink chip, anowncord://message/…link from the OS — now routes through a single jumper (lib/message-navigation.tsregistry →main-page/MessageJump.ts): scroll + flash when the target is loaded, otherwise fetch the around-window, swap it in, scroll + flash. A window with newer messages below it is detached: the store refuses to append live broadcasts onto it (they belong below a gap) and the list shows a Jump to Present pill that reattaches and refetches the tail. Permalinks areowncord://message/{channelId}/{messageId}— copied from the hover bar, parsed by the samedeep-link.tsthat owns the invite scheme (whose bare-code form now refuses themessageroute), and rendered as a compact channel-name chip when pasted into chat; a link to a channel the reader cannot see stays plain text. - DONE (2026-08-01) — Who-reacted list. Server added
GET /api/v1/channels/{id}/messages/{messageId}/reactions/{emoji}/users(emoji percent-encoded; chi routes onRawPath, so the handler unescapes it) behind the samerequireChannelReadgate as history, returning up to 100 reactors oldest-first. A separate endpoint rather thanuser_idsinline on every reaction summary: a page of chat carries dozens of pills and almost none are hovered, so the payload stays small. A message that lives in another channel is a 404 — the channel in the URL is the one the permission check ran against. Client: hovering or focusing a pill for 300 ms (thelib/streamPreview.tsdebounce, so a pointer crossing a row fires nothing) fetches and shows "alice, bob, carol and 4 others reacted with 👍". Lists are cached per message+emoji and evicted wholesale for a message onreaction_update, which names only the emoji that changed; a response that lands after an invalidation or after the pointer left is discarded rather than repopulating the cache or popping a tooltip nobody is hovering. Usernames go in as text nodes. - DONE (2026-08-01) — Inline video/audio players.
video/mp4|webm|oggrender as<video controls preload="metadata">inside the same max box as an image (download button on hover); the common audio containers render as an<audio controls preload="metadata">row with filename, size and download. Both are allowlists, notvideo//audio/prefix tests — an unknown container gets the download chip rather than a player that fails to decode — andimage/svg+xmlis now excluded from the image path too (it can carry script, and the data-URI allowlist already refused it, so inlining only ever produced a stuck placeholder)./api/v1/files/{id}is permission-checked, so the source is fetched through the same cert-pinned proxy with the session bearer token images use, then handed over as ablob:URL rather than the image path's base64 data URI, which would inflate a 50 MB video into a string and cache it in IndexedDB. - DONE (2026-08-01) — Read-state polish. A red NEW divider marks the first unread message when a channel is opened with unread; because opening clears the badge,
setActiveChannelsnapshots the count first (getUnreadOnOpen) and the list places the line above the last N loaded messages — suppressed while the window is detached (a slice around an old message is not the tail) and gone on the next visit. Explicit mark-as-read arrived as a new client→server WS messagemark_read:channel_focusalready advances read state but also rebinds the connection's focused channel, which is wrong when marking a channel the user is not looking at. It backs Mark as Read in the channel context menu (disabled when already read, absent for voice) and Mark All as Read on the sidebar's server header, which only appears while something is unread. DM sidebar rows now show real unread counts and a red mention count that outranks them;GetChannelUnreadCountsincludes the caller's DM rows soreadyships a DMmention_count— previously absent, which silently reset every DM mention badge on reconnect.
Phase 5 — roles & channels management
- DONE (2026-08-01) — Role CRUD. Roles were four seeded rows whose permission masks were frozen at migration time; they are now real entities behind
/admin/api/roles(GET,POST,PATCH /{id},DELETE /{id},PATCH /roles/reorder), gated onMANAGE_ROLESwith the whole rule set in a newservice.RoleServicerather than in the handlers. Every rule is measured against the actor's role position: you may only create/edit/delete/reorder roles strictly below your own (equality is refused too, so a role cannot rewrite itself, and nothing outranks position 100 — which is what makes the seeded Owner role immutable and undeletable for everyone including the owner), and you may never grant a bit your own role lacks, though removing one is allowed because de-escalation is always safe (ADMINISTRATORbypasses). The default role is undeletable — every member falls back to it — and deleting a role moves its members onto that fallback in oneUPDATE, drops the role'schannel_overridesrows and deletes the role in a single writer transaction, then invalidates exactly the moved members' cached permissions. Names are unique case-insensitively (migration023addsidx_roles_name_nocase; the column's ownUNIQUEis BINARY, so "Moderator" and "moderator" used to be two roles the client's case-insensitive lookup could not tell apart), colors are#rgb/#rrggbbnormalized to uppercase, and unknown permission bits are masked off rather than rejected. Reorder takes an ordered id list that must name exactly the roles below the actor — a partial list is refused rather than leaving the omitted ones at positions that now collide — and normalizes them toN…1. Every mutation audits (role_create/role_update/role_delete/role_reorder). Cache and client sync follow the existing patterns rather than inventing one: the permission cache is invalidated before the hub calls (as the channel-override handlers do), a permission change runs the newHub.RefreshAllChannelVisibility—RefreshChannelVisibilityacross every non-DM channel, because a role's mask is the base every channel's effective permission derives from, where an override edit touches exactly one — and a delete additionally sends onemember_updateper reassigned member. A newroles_updateserver→client message (schema +make protocol-generate+docs/protocol.md) carries the full new list, so clients refreshchannelsStore.roleswithout reconnecting; replacing rather than patching means a dropped intermediate event can never leave a deleted role on screen. The member list now subscribes to that list too — grouping, labels and name colors all derive from it, and before this they only re-rendered when some unrelated member change happened along. The admin panel grows a Roles section (nav gated onMANAGE_ROLES) listing roles by position with a color swatch and member count, up/down reorder arrows scoped to the manageable slice, a create/edit modal with a permission checkbox grid grouped asdocs/schema.mdgroups the bitfield — bits the caller's own role lacks are rendered disabled — and a delete confirmation that names how many members move and where. Hoist and mentionable are still out of scope: neither has a column, and role mentions need the mention resolver to learn about roles. - Role CRUD leftovers: hoist and mentionable flags (no columns yet), role mentions (
@RoleName). - DONE (2026-08-01) — Per-user channel overrides + the full override matrix UI. New table
channel_user_overrides(migration024, PK(channel_id, user_id)plusidx_channel_user_overrides_userfor the per-user direction) makes the resolution order Discord's: base role permissions → role override → user override, with the narrower layer last, so a user deny beats a role allow and a user allow beats a user deny;ADMINISTRATORstill bypasses both. The formula has exactly one implementation,permissions.EffectiveChannelPerms, whichChecker.HasChannelPerm,Checker.HasChannelPermBatchand through itVisibleChannelIDsall route through — so extending the order was a change to one function plus the fetch, not to the dozens ofHasChannelPermcall sites.HasChannelPermgrew auserIDparameter (0= "no member in hand", skip the user layer), and both layers are loaded together bydb.GetChannelOverridesFor(roleID, userID)— two batch queries, never per channel — which is now the single fetch behindbuildReady,computeAllowedChannels, RESTListVisibleChannels,MessageService.GetAccessibleChannelIDs, the voice-join publish grants and the cachedservice.PermissionService.channelCanSendresolves both layers too, and the@everyonefan-out (mentionReaders) folds the user layer in both directions: a user deny drops a reader the role walk admitted (unless they holdADMINISTRATOR), a user allow adds one it excluded.Hub.RefreshChannelVisibilityandchannelReadAudiencestopped memoising visibility per role — two members of one role can now legitimately disagree about a channel, which is exactly what a per-user override edit creates.Server/ws/channel_visibility_agreement_test.gogrew a second case proving REST,readyand replay filtering still agree for three members of the same role carrying different overrides. API:PUT/DELETE /admin/api/channels/{id}/user-permissions/{userId}with{allow, deny}masks, gatedMANAGE_CHANNELSlike the role layer, unknown bits masked off, audited aschannel_user_perms_update/channel_user_perms_clear. They invalidate only the target's cache (InvalidateUser) rather than the whole cache the role layer must drop — a per-user override cannot change anyone else's verdict — before the hub re-sync.GET .../permissionsnow returnsusersalongsideroles: every role (zero masks when unset) but only the members who actually carry an override row. The admin panel's single "Can access" checkbox survives as the quick private-channel shortcut, writing exactly the mask it always did, and gained a real matrix editor beneath it: pick a role or a member, then set allow / inherit / deny per channel-scoped bit (READ, SEND, ATTACH_FILES, ADD_REACTIONS, MANAGE_MESSAGES, MENTION_EVERYONE, CONNECT, SPEAK, VIDEO, SHARE_SCREEN). An all-inherit row is sent as aDELETE, because storing(0,0)would leave a row that resolves to nothing.perm_grid_test.goties the matrix's bit list topermissionsthe same way it already tied the role grid. - DONE (2026-08-01) — Categories stopped being magic strings. The server refused any non-voice channel under a category literally named "Voice Channels" and any voice channel outside it (
validateCategoryType), and the client mirrored the rule with a substring test on the category name. Both are gone:POST /admin/api/channelsvalidates the type alone, categories are free text, and any type lives under any name.PATCH /admin/api/channels/{id}acceptscategory, so moving a channel between categories is an edit rather than a delete-and-recreate. The desktopCreateChannelModal's read-only category display became an editable text input with a<datalist>of the categories in use (channelsStore.getKnownCategories), offering all three types;EditChannelModalgained the same field; the admin panel's create and edit forms got the same input plus datalist. The sidebar groups voice channels under whatever category they carry — sharing a group with text channels is fine — and falls back to a synthetic "Voice" group only for voice channels with no category at all (displayCategoryOf). Collapse persistence stays client-side, unchanged. - Categories as real entities (own permissions, ordering).
- DONE (2026-08-01) — Channel management moved into the desktop client.
EditChannelModaloffered name, topic and category; it now also carries slow mode (a preset<select>from Off to the server's 6-hour ceiling — a free number field mostly produces typos like "300" meant as minutes, and a stored off-preset value set through the admin panel is kept as its own option rather than silently rounded), an NSFW toggle, and a voice section (User Limit / Video Limit, 0–99, 0 = unlimited) rendered for voice channels alone — the columns exist on every row, but on a text channel they are values nothing reads, so a text-channel edit omits the keys entirely rather than sending0and wiping limits the row happens to hold. Every control pre-fills fromchannelsStore(whichchannel_updatewrites into), not from the sidebar row, so the modal opens on current values.PATCH /admin/api/channels/{id}anddb.AdminUpdateChannelgrewnsfw,voice_max_usersandvoice_max_video; the positional argument list becamedb.ChannelUpdateonce it reached nine fields, four of them ints. Bounds (slow_mode0…21600, both voice limits 0…99) are validated before the write and refused with400 INVALID_INPUTrather than clamped — a caller that sent-1meant something — so a rejected body writes nothing at all. The whole UI is gated on MANAGE_CHANNELS, not on role names:permissions.canManageChannels()is now the single derivation behind the category "+" and the context menu's Edit/Delete, which were still asking whether the role was literally called "owner" or "admin" (a custom role the server would happily let edit a channel saw no way to, and a role merely called "admin" with no channel bit saw items every click would be refused for).channel_create/channel_updateandreadyall carryslow_mode,nsfwand both voice limits — always present with their zero values, never omitted, so "absent" never means two things — built by onechannelPayloadFromconstructor so the two events cannot drift. The store applies a partialchannel_updatefield by field (an absent key is left alone, not cleared) and finally handlescategory, so a category move regroups the sidebar without a reconnect. - DONE (2026-08-01) — NSFW flag end-to-end, and the voice limits surfaced. Migration
025addschannels.nsfw(0/1, likearchived). The server does nothing with it and says so inschema.md,api.md,protocol.mdand the migration itself: it stores, broadcasts and audits the flag (updated #foo (marked NSFW)/(unmarked NSFW), plain when it did not move) and applies no filtering, no age check and no restriction on who may read or post — a client ignoring the field behaves exactly as before it existed. Every consequence is the desktop client's:@lib/nsfw-gateremembers acknowledgement per channel in sessionStorage (the promise is "once per session", so localStorage would quietly make it "once ever"; a throwing storage reads as not acknowledged, erring toward asking again), andNsfwGatemounts over the messages slot — not as a modal, since the channel is live underneath and the sidebar stays usable — with "This channel may contain sensitive content — Continue?", a note stating plainly that nothing is filtered, and a Go Back that leaves the channel rather than stranding the reader. The sidebar marks flagged channels with a shield beside the name (not a recolour: unread/mention/active already own the row's colour). Voice limits: the row shows "3/5" when a user limit is set and nothing when unlimited ("3/0" would read as a bug), and the client still never pre-blocks a join — its participant list can lag and an invented refusal would be uncorrectable, so the server answersCHANNEL_FULL, which the dispatcher now surfaces as a toast (it was logged and otherwise silent, as wasVIDEO_LIMIT). - DONE (2026-08-01) — The audit log stays admin-panel-only — it is a paginated, filterable table over an endpoint the desktop client otherwise never calls, and a second implementation would be a second thing to keep correct — but it stopped being unreachable. The sidebar's server header grows an "Audit Log" entry gated on
VIEW_AUDIT_LOG(kept in sync withauthStoreand the role list, becausereadycan land after the header is built), openinghttps://{host}/admin#auditin the user's browser via the opener plugin. Deliberately not through the loopback TOFU proxy the REST client uses: that origin means nothing to an external browser, so a self-signed deployment shows the browser's certificate warning, which is the honest outcome. The admin panel learned to honour a#sectionfragment on load (falling back to the dashboard when the principal may not open it, exactly as a stale stored section does), so the entry lands on the log rather than on the dashboard with a tab still to find.
Phase 6 — social & profiles
- DONE (2026-08-01) — Custom emoji end-to-end. The
emojitable had shipped in migration001with zero server code, and the client carriedgetEmoji/deleteEmojimethods aimed at routes nobody had registered plus anEmojiPickeroption nothing ever passed; all three are now real. Server:GET /api/v1/emoji(open to any member — an emoji nobody can render is not an emoji, and the set is server-wide with no per-channel scope to leak),POST /api/v1/emojiandDELETE /api/v1/emoji/{id}gated on MANAGE_SERVER. No new permission bit was added, and that is the decision rather than an omission: a bit is a schema-visible, forever choice, and "who may change server-wide branding" is exactly what MANAGE_SERVER already answers for the server name, icon and settings. The gate runs before the multipart body is read, so a member without it never causes a spool to disk. Uploads are capped at 512 KiB, sniffed from their own bytes (image/png|jpeg|gif|webponly — SVG is refused outright: it is markup with script and external-fetch capability, and an emoji is by definition rendered inline), and re-measured from the sniffed image against a 128×128 ceiling; WebP headers are parsed by hand (webpDimensions, all three of VP8/VP8L/VP8X) because the standard library has no WebP decoder and none was vendored for a dimension read. Shortcodes are[a-z0-9_]{2,32}, lowercased on the way in — which is what makes the table's plainUNIQUEindex a case-insensitive one without aCOLLATEchange — with a collision answering409 CONFLICTand a 200-emoji cap per server. Bytes go through the existing storage layer under a UUID; migration026adds the one column the table lacked,mime_type, soGET /api/v1/emoji/{id}/imagecan set a Content-Type without re-sniffing the file on every request. That route is authenticated (an emoji must not be usable as an unauthenticated tracking pixel) but has no per-channel ACL to apply, and isimmutable-cacheable because an emoji's bytes never change for a given id. A failed insert unlinks the orphaned file; a failed unlink after a successful delete is logged rather than failing the delete. Newemoji_updateserver→client message (schema +make protocol-generate+docs/protocol.md) carries the whole set after every mutation, for the same reasonroles_updatedoes: replacing rather than patching means a dropped event can never leave a deleted emoji rendering in the messages that name it. It is deliberately not in thereadypayload — the set belongs to the server, not the session, so clients load it once over REST on ready and keep it fresh from the event. Client: a newemojiStorewhoseresolveEmojiis the single answer to "is:name:a real emoji here", consulted by message rendering, the picker, the composer autocomplete and reaction pills so none of them can disagree.:shortcode:renders as a 22px inline image — jumbo 48px when the message is nothing but emoji (unicode included, capped at Discord's 27, and an unresolved shortcode is plain text so it never earns jumbo) — via a.msg-text-jumboclass that sizes glyphs and images together rather than threading a flag through four render functions. It is added in the same token pass as@mentionsand#channels, so code spans and fenced blocks are excluded for free: inline code never reaches the token pass, and fences are split off before it. Images are fetched through the same cert-pinned, bearer-token path attachments use and swapped in as a data URI — assigning the server URL toimg.srcwould 401 — with the shortcode asalt, so a message reads correctly before (and if) the bytes arrive. Reactions are free-form strings already, so a custom reaction is stored as the literal:shortcode:and the pill renders the image when it resolves and the text when it does not (a deleted emoji leaves a working, if plain, reaction). The reaction length cap stopped being a bare32and is now derived asMaxShortcodeLen + 2: a 31- or 32-character shortcode was a legal emoji that rendered in messages and was silently refused as a reaction, which is exactly the kind of gap a hardcoded constant on each side produces. The composer's picker finally gets itscustomEmojioption, showing a Server category that inserts:shortcode:, and gained a:-autocomplete mirroring the@-mention one — colon plus 2+ characters, custom emoji ranked above the built-in unicode set, only one popup open at a time. The admin panel grows an Emoji section (nav gated onMANAGE_SERVER) with upload, list and delete; it calls the ordinary member API rather than a duplicate/admin/apihandler set, and loads thumbnails as blob URLs because<img src>cannot send an Authorization header (the panel's CSP gainedimg-src 'self' blob:for exactly that). - DONE (2026-08-01) — Profiles & presence depth: avatar upload, display names, about-me, custom status, real invisible, auto-idle. Migration
027addsusers.display_name(32),about(300) andcustom_status(128) — all nullable, all bounded and HTML-sanitized inUserService/ChannelServicerather than in a handler, so every transport gets the same rules and "omitted = unchanged, empty string = cleared" is one decision rather than four.display_nameis display-only on purpose:@mentionskeep resolving againstusername, because it is the unique case-insensitive key and a non-unique nickname would make@aliceambiguous the moment two people pick the same one.POST /api/v1/users/me/avatartakes a multipart PNG/JPEG/WebP (1 MiB, 1024×1024, both re-measured from the sniffed bytes; GIF is refused because an animated avatar renders in every message row, SVG for the reason emoji refuse it). The bytes land in the ordinary attachments table with no channel andusers.avataris pointed at/api/v1/files/{id}— which is what makes the picture readable: an unlinked attachment is uploader-only, and the file route now also admits one that some user's avatar column currently equals (covered by a partial index added in the same migration). An avatar is public exactly while it is somebody's avatar and stops being readable the instant it is replaced; the previous file's bytes are deliberately left on disk, since a blind delete would race any request already in flight for a message rendered with it.PATCH /users/mestill takes an https URL, and both paths end at the same column. Real invisible is the load-bearing change.users.statusstores the status the user chose, invisible included; the collapse toofflinehappens at read time in exactly two functions (db.BroadcastStatus,db.StatusForViewer) that every payload builder delegates to, so a new payload cannot leak it by forgetting. A presence change to invisible splits into two events — a global broadcast excluding the owner that saysoffline, and a targeted one carrying their true state — because a client told it was offline would render its own picker wrong and re-announce online on the next reconnect. That reconnect flash is gone at the source too:ws serveno longer stampsonlineon connect, it reads the saved status (db.ConnectStatus: idle/dnd/invisible survive, anything else becomes online) and announces that, beforebuildReadyruns so the member list and the broadcast cannot disagree. A chosen status now survives a disconnect (MarkUserDisconnectedclears onlyonline) and a restart, and the stale-choice problem that would otherwise create is handled at read time: a member with no live connection renders offline whatever the column says. The client'srestoreSavedPresenceshrank to a no-op safeguard that only speaks up when the server genuinely disagrees. The one place that read the column as a value rather than through the two collapse functions was the@herefan-out, which testedstatus == "offline"literally and so would have pinged exactly the people who had asked not to be seen; it now collapses throughdb.BroadcastStatusfirst, so@hereskips invisible readers and@everyonestill reaches them. Custom status rides the presence payload rather than getting its own message:presence_updatetakes an optionalcustom_statuswhere omitted means "leave it alone" and""clears — a distinction that exists because the auto-idle timer sends a bare status flip several times an hour and must not blank the text the user typed. It persists across reconnects and is cleared on logout (a "what I am doing right now" note that outlives the session states something no longer true, unlike the status itself, which is a preference). Auto-idle is client-side (@lib/autoIdle): ten quiet minutes → idle, any input → online, input listening throttled to 1 Hz because mousemove fires hundreds of times a second against a timer measured in minutes. Its whole safety property is one function,nextAutoStatus: only a manual Online becomes an automatic Idle, and only an automatic Idle goes back to Online — a manually chosen Idle is a statement, and dnd/invisible are never touched in either direction. That neededuserStatusto record who chose the status ("auto" vs "manual"), which is also what lets a stored pre-phase-6"offline"be migrated toinvisibleon read. Client: a shared@lib/avatarhelper is now the single answer to "how do I draw this user" — message rows, the reply preview, the member list, the user bar, the profile popup and the account card all went through it, and it fetches the authenticated file through the same cert-pinned bearer-token path attachments and emoji use (an<img src>cannot carry an Authorization header, so the URL would 401) while keeping the letter as the fallback until and unless the bytes arrive. Display names render everywhere with a username fallback, resolved from the member store first so a rename patches messages already on screen; the popup shows the@handleunderneath so the thing you would actually type is still one glance away. Theaboutsection the popup has rendered since the quick-wins phase finally has real data behind it. The Account tab grew an avatar uploader (client-side type/size/dimension check mirroring the server's, so a refusal costs no upload) plus display-name and about fields, and the StatusPicker gained a custom-status input and sendsinvisibleas its own value. - DONE (2026-08-01) — Group DMs.
dm_participantsalways held N rows per channel; what was missing was a create path, a way to tell a group from a two-person DM, and a client that did not assume one recipient. Server:POST /api/v1/dms/group(2–8 others, 3–10 total),PATCH /api/v1/dms/{id}to set or clear the name (any participant may — a group DM has no owner column and no roles, so that is the only rule that does not require inventing an ownership model; a 1:1 refuses, since its name is who is in it), andDELETE /api/v1/dms/{id}which is now two operations behind one gesture: a hide for a 1:1 (unchanged) and a leave for a group, deleting the channel when the last participant goes. Migration028addschannels.is_group, and that column rather than a participant count is the load-bearing decision. A group of three that two people leave has two participants, and the 1:1 lookup — "the dm channel both of these users are in" — would then match it, so "message Bob" would silently deliver into the remnants of a group in front of whoever was still there. The same count would also make leaving destructive for the third-from-last leaver and non-destructive for the second-from-last. Group-ness is therefore decided once at creation and never recomputed.db.DMChannelInfogrewrecipients,nameandis_group, andrecipientstayed as the first ofrecipientsso a pre-group client still renders somebody;db.NewDMChannelInfois the single place that answers "which of these is the recipient", soGET /dms, thereadypayload anddm_channel_opencannot disagree about a channel.dm_channel_openis now built per viewer —recipientandrecipientsare defined relative to who is reading, so one shared payload would list a group member as their own DM partner.GetUserDMChannelsbecame two queries (channel rows + every participant of every open DM) rather than one, because a single joined query returns one row per (channel, participant) pair and the caller has to de-duplicate anyway. Blocks are a 1:1 rule, which is Discord's semantics and the only coherent one for a shared room:requireDMNotBlockedexempts groups, because dropping one member's messages for one other member would leave the two of them reading different conversations under the same name. They are enforced at creation instead — a user may neither add someone they have blocked nor add someone who has blocked them — where "may these two be in a room together" still has one answer. The client's composer gate follows the same line and applies to 1:1 DMs alone. Message/typing/read fan-out already went throughdm_participantsand needed no change; the tests pin that it genuinely reaches the third member. Voice in a group DM works through the existing participant check (hasChannelAccess→IsDMParticipant), also unchanged. Client: DM rows are keyed on the channel, not the recipient — a group has no single recipient, and the same person can be in both a 1:1 and a group with you — with stacked avatars, a participant count, anddmDisplayNameas the one answer to "what is this conversation called" (a group's name, else its members joined and capped at three plus a count, else the other person). The New DM picker became multi-select with one button relabelled by the selection size, because "new conversation" is one intent and making the user pick "DM" or "group" before choosing who is in it asks them to declare it twice. - DONE (2026-08-01) — DM calls with ringing. New
call_ring/call_decline(client→server) andcall_incoming/call_declined(server→client) via the schema +make protocol-generate. No new DB state: a call in a DM is presence in that DM's voice channel, whichvoice_statealready broadcasts, and ringing is transient signalling on top. A persisted call row would be one more thing a crashed client can leave dangling in exchange for information the presence already carries.call_ringis participant-gated and rate limited to one every 3 seconds per user (not per channel — the abuse is spamming somebody with banners); the fan-out reaches whichever participants are connected, since a targeted event to an offline user is a no-op by construction and a ring that arrives after the fact is worse than no ring.call_declineis addressed to all other participants rather than "the ringer", because with no call state the server does not know who that was, and in a group more than one person may be ringing. Client: the DM header gained a Call button that joins the voice channel before ringing — the ring is only truthful once the caller is actually there. Incoming calls draw a banner (not a modal: a ring is an offer, and blocking the app until a 30s timer expires is not one) with Accept/Decline and a repeating chime. The whole lifetime is a statechart in@lib/call-ringwith no DOM in it — accept, decline, 30s timeout,call_declined, and the ringer'svoice_leaveall exit through onestopRinging, so there is exactly one place that can leave the chime playing. A timeout deliberately sends no decline: it means "nobody was there", and claiming a refusal that did not happen would be a lie to the ringer. - DONE (2026-08-01) — Friends list: the dead nav item is removed, which is the option this plan already listed. It was a row in the DM sidebar whose
onFriendsClickno call site ever passed and whosefriendsActiveno call site ever set; building a friends list behind it would have meant a follow/request model, a table, and a second notion of "who can DM whom" alongside blocks. The item, both dead props and its CSS are gone, and a test pins the deletion. - DONE (2026-08-01) — Per-channel notification mutes. Client-side prefs in
localStorage(@lib/channel-mutes), because the server has no per-user channel settings table and "which of my devices bothers me" is a property of the device, not the account — the same reasondesktopNotificationsandnotificationSoundslive next to it. Discord's semantics exactly: a muted channel fires no desktop notification, no chime and no taskbar flash (a flashing taskbar is precisely the interruption the mute was asked for); its unread badge still counts but renders dimmed, because the channel has not stopped existing, it has stopped shouting; and a message that mentions you still notifies and still shows the red mention badge. That last rule is what makes a mute safe to use, and it lives in one function (notificationAllowed) so the popup, the chime and the flash cannot end up applying three slightly different copies of it. Channel and DM context menus gained Mute/Unmute (until turned off — a timed mute needs a stored expiry the client would have to sweep, to buy an affordance the user can reproduce by unmuting), and the Notifications tab lists what is muted with unmute buttons, including mutes that outlived their channel, since otherwise there is no way to clear them.
Absent wholesale (not planned here)
Threads, forum/stage channels, webhooks, bot accounts, stickers, polls,
message forwarding, TTS, soundboard (dead sounds table), priority speaker,
streamer mode, video backgrounds, multi-guild, email/account recovery,
slash commands (separate plan: slash-commands.md).
Known dead code to reconcile as phases land
Still dead after phase 6 (nothing in this plan reconciles them):
soundstable — the soundboard is absent wholesale, so the table has no feature to belong to; it and the client'sgetSounds/deleteSound, which still call unregistered routes, are the largest remaining piece.voice_speakersreserved WS type (never sent).voice_config.bitrate— sent to clients, never applied client-side.- PTT stub on macOS.
Came off the list:
read_states.mention_count(phase 3) — now written, shipped inreadyand cleared bychannel_focus.- The
emojitable and the client'sgetEmoji/deleteEmoji(phase 6) — the table is written by/api/v1/emoji, and the two dead methods were replaced bylistEmoji/uploadEmoji/deleteEmojiagainst the real routes. UserProfilePopup'saboutsection (phase 6) — built, styled and tested while every call site passed a hardcodednull;users.aboutnow feeds it.- The DM sidebar's Friends nav item (phase 6) — deleted rather than
implemented. Its
onFriendsClickwas never passed by any call site and itsfriendsActivewas never set; giving it a destination would have meant a follow/request model, a table and a second notion of "who may DM whom" alongside blocks. The item, both dead props and its CSS are gone, and a test pins the deletion.