Files
OwnCord/docs/plans/discord-parity.md
T
J3vbandClaude Fable 5 03fcb7d518 fix: execute the 2026-08-19 audit fix order (docs refresh + five FRAGILE fixes) (#1396)
* 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>
2026-08-20 03:49:13 +02:00

50 KiB
Raw Blame History

Discord feature parity — gap analysis and plan

Status (verified 2026-08-04): Shipped — phases 16 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 in ready via channelPayloadFrom; role colors from the server list with seeded-name fallback formatting.ts:158-175; profile popup mounted from MemberList.ts; temp bans BanDurationHours in PATCH /admin/api/users/{id}; archived channels hidden by the unified predicate permissions/checker.go:116-121). Named leftovers remain open and are listed in-line: role hoist/mentionable flags + @RoleName mentions (Phase 5), categories as real entities (Phase 5), and the §"still-dead code" cleanup list (voice_speakers, voice_config.bitrate, macOS PTT stub — the sounds table 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_bytes is 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}/sessions cannot 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, the KICK_MEMBERS bit and the onKick/adminKickMember call sites are unchanged.
  • DONE (2026-07-31) — Enforce the decorative permission bits. The /admin/api perimeter now admits any role holding a bit of permissions.AdminPerimeter instead of requiring ADMINISTRATOR, 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 inside ModerationService. Stats/users/GET /me stay perimeter-level; backups, updates, API tokens, plugins and the log stream are unchanged. GET /admin/api/me reports 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 the ready role list instead of on the role name. MUTE_MEMBERS admits to the perimeter but still has no route behind it (see voice moderation below).
  • DONE (2026-07-31) — Hierarchy checks beyond ban/unban: ModerationService.ChangeUserRole requires 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.ForceLogout enforces 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_move and voice_mod_kick, each requiring the bit plus a strict role-position outrank of the target, rate limited 5/sec and audit-logged. voice_states gained server_muted / server_deafened, which the voice_state broadcast now carries; a server mute is also applied to the target's published audio track via the LiveKit RoomService, and the target's own voice_mute / voice_deafen unmute attempts are refused with SERVER_MUTED / SERVER_DEAFENED. Move and disconnect run the hub's voice-leave routine for the target and then send them voice_moved (client re-joins the destination through the ordinary join path) or voice_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/purge takes {limit: 1-100, before?} and soft-deletes the newest matching messages, gated on READ_MESSAGES|MANAGE_MESSAGES for 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 and reply_to targets survive; already-deleted rows are skipped and the select+update run in one writer transaction. One message_purge audit entry per call carries the count, and the fan-out is a single new chat_bulk_deleted {channel_id, ids} server->client message instead of N chat_deleted events. The desktop channel context menu grows a "Purge Messages…" item — gated on the actor's MANAGE_MESSAGES bit 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.resolveMentions parses @tokens out of sanitized content with a word-boundary rule (mentionTokenRe) that refuses address-shaped text: mail@example and @@name never match, and @bob@example.com is rejected whole rather than half-matched. Tokens are lowercased, deduplicated, ordered by first appearance and resolved case-insensitively against users.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 new message_mentions table (migration 022, PK (message_id, mentioned_user_id) plus idx_message_mentions_user for 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. mentions and mentions_everyone now ride on the chat_message and chat_edited broadcasts, on GET /channels/{id}/messages, on pinned-message responses and on FTS search results; mentions is always present and empty rather than null. buildChatMessage took a chatMessageArgs struct in the process — the positional list had outgrown a readable call site.
  • DONE (2026-07-31)read_states.mention_count is live. applyMentionCounts raises 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: a ListBlockersOf error 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_focus resets the count to 0 via the UpdateReadState upsert, and the ready payload ships mention_count per channel. Because GetChannelUnreadCounts covers text/announcement channels, 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/mentions is 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's mentions/mentions_everyone decide the outcome whenever present (the local token parse only stands in for servers predating the fields). Resolved mentions render as a highlighted .mention span, with .mention-self when the mention is the current user; an unresolvable token renders as plain text. In the channel sidebar a red .mention-badge outranks 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/@here alone caused, so a message that also names you still notifies, and an @everyone the 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/@here behind a permission, plus composer autocomplete. New MENTION_EVERYONE bit (21, 0x200000); migration 022 grants it to the seeded Owner/Admin/Moderator roles, moving the Moderator mask from 0x000FFFFF to 0x002FFFFF. Without the bit the token carries no mention semantics at all — no highlight, no badge, no notification — and DM channels have no @everyone semantics since there is no permission surface to answer to. @here narrows the fan-out to readers whose status is not offline; @everyone reaches 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/@here appear as rows only for users who hold the bit.
  • DONE (2026-07-31) — Clickable #channel links. #name tokens 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 #channel links, 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 so snake_case_names stay 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 absolute http(s) only — javascript:, data: and relatives render as their literal source — and are excluded from extractUrls, 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-span role="button" elements with aria-pressed, and the revealing click is swallowed so a link underneath cannot open with it. Rendering stays a strict DOM builder: no innerHTML anywhere, every href through isSafeUrl. 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, with has_more_before/has_more_after derived 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 in MessageService collapsed into one requireChannelRead, and the three copies of limit parsing in the handlers into one parseLimitParam. Client-side every jump affordance — search hit, pinned entry, the quoted reply bar, a permalink chip, an owncord://message/… link from the OS — now routes through a single jumper (lib/message-navigation.ts registry → 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 are owncord://message/{channelId}/{messageId} — copied from the hover bar, parsed by the same deep-link.ts that owns the invite scheme (whose bare-code form now refuses the message route), 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 on RawPath, so the handler unescapes it) behind the same requireChannelRead gate as history, returning up to 100 reactors oldest-first. A separate endpoint rather than user_ids inline 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 (the lib/streamPreview.ts debounce, 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 on reaction_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|ogg render 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, not video//audio/ prefix tests — an unknown container gets the download chip rather than a player that fails to decode — and image/svg+xml is 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 a blob: 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, setActiveChannel snapshots 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 message mark_read: channel_focus already 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; GetChannelUnreadCounts includes the caller's DM rows so ready ships a DM mention_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 on MANAGE_ROLES with the whole rule set in a new service.RoleService rather 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 (ADMINISTRATOR bypasses). The default role is undeletable — every member falls back to it — and deleting a role moves its members onto that fallback in one UPDATE, drops the role's channel_overrides rows and deletes the role in a single writer transaction, then invalidates exactly the moved members' cached permissions. Names are unique case-insensitively (migration 023 adds idx_roles_name_nocase; the column's own UNIQUE is BINARY, so "Moderator" and "moderator" used to be two roles the client's case-insensitive lookup could not tell apart), colors are #rgb/#rrggbb normalized 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 to N…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 new Hub.RefreshAllChannelVisibilityRefreshChannelVisibility across 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 one member_update per reassigned member. A new roles_update server→client message (schema + make protocol-generate + docs/protocol.md) carries the full new list, so clients refresh channelsStore.roles without 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 on MANAGE_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 as docs/schema.md groups 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 (migration 024, PK (channel_id, user_id) plus idx_channel_user_overrides_user for 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; ADMINISTRATOR still bypasses both. The formula has exactly one implementation, permissions.EffectiveChannelPerms, which Checker.HasChannelPerm, Checker.HasChannelPermBatch and through it VisibleChannelIDs all route through — so extending the order was a change to one function plus the fetch, not to the dozens of HasChannelPerm call sites. HasChannelPerm grew a userID parameter (0 = "no member in hand", skip the user layer), and both layers are loaded together by db.GetChannelOverridesFor(roleID, userID) — two batch queries, never per channel — which is now the single fetch behind buildReady, computeAllowedChannels, REST ListVisibleChannels, MessageService.GetAccessibleChannelIDs, the voice-join publish grants and the cached service.PermissionService. channelCanSend resolves both layers too, and the @everyone fan-out (mentionReaders) folds the user layer in both directions: a user deny drops a reader the role walk admitted (unless they hold ADMINISTRATOR), a user allow adds one it excluded. Hub.RefreshChannelVisibility and channelReadAudience stopped 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.go grew a second case proving REST, ready and 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, gated MANAGE_CHANNELS like the role layer, unknown bits masked off, audited as channel_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 .../permissions now returns users alongside roles: 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 a DELETE, because storing (0,0) would leave a row that resolves to nothing. perm_grid_test.go ties the matrix's bit list to permissions the 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/channels validates the type alone, categories are free text, and any type lives under any name. PATCH /admin/api/channels/{id} accepts category, so moving a channel between categories is an edit rather than a delete-and-recreate. The desktop CreateChannelModal's read-only category display became an editable text input with a <datalist> of the categories in use (channelsStore.getKnownCategories), offering all three types; EditChannelModal gained 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. EditChannelModal offered 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, 099, 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 sending 0 and wiping limits the row happens to hold. Every control pre-fills from channelsStore (which channel_update writes into), not from the sidebar row, so the modal opens on current values. PATCH /admin/api/channels/{id} and db.AdminUpdateChannel grew nsfw, voice_max_users and voice_max_video; the positional argument list became db.ChannelUpdate once it reached nine fields, four of them ints. Bounds (slow_mode 0…21600, both voice limits 0…99) are validated before the write and refused with 400 INVALID_INPUT rather than clamped — a caller that sent -1 meant 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_update and ready all carry slow_mode, nsfw and both voice limits — always present with their zero values, never omitted, so "absent" never means two things — built by one channelPayloadFrom constructor so the two events cannot drift. The store applies a partial channel_update field by field (an absent key is left alone, not cleared) and finally handles category, 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 025 adds channels.nsfw (0/1, like archived). The server does nothing with it and says so in schema.md, api.md, protocol.md and 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-gate remembers 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), and NsfwGate mounts 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 answers CHANNEL_FULL, which the dispatcher now surfaces as a toast (it was logged and otherwise silent, as was VIDEO_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 with authStore and the role list, because ready can land after the header is built), opening https://{host}/admin#audit in 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 #section fragment 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 emoji table had shipped in migration 001 with zero server code, and the client carried getEmoji/deleteEmoji methods aimed at routes nobody had registered plus an EmojiPicker option 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/emoji and DELETE /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|webp only — 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 plain UNIQUE index a case-insensitive one without a COLLATE change — with a collision answering 409 CONFLICT and a 200-emoji cap per server. Bytes go through the existing storage layer under a UUID; migration 026 adds the one column the table lacked, mime_type, so GET /api/v1/emoji/{id}/image can 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 is immutable-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. New emoji_update server→client message (schema + make protocol-generate + docs/protocol.md) carries the whole set after every mutation, for the same reason roles_update does: 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 the ready payload — 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 new emojiStore whose resolveEmoji is 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-jumbo class that sizes glyphs and images together rather than threading a flag through four render functions. It is added in the same token pass as @mentions and #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 to img.src would 401 — with the shortcode as alt, 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 bare 32 and is now derived as MaxShortcodeLen + 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 its customEmoji option, 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 on MANAGE_SERVER) with upload, list and delete; it calls the ordinary member API rather than a duplicate /admin/api handler set, and loads thumbnails as blob URLs because <img src> cannot send an Authorization header (the panel's CSP gained img-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 027 adds users.display_name (32), about (300) and custom_status (128) — all nullable, all bounded and HTML-sanitized in UserService/ChannelService rather than in a handler, so every transport gets the same rules and "omitted = unchanged, empty string = cleared" is one decision rather than four. display_name is display-only on purpose: @mentions keep resolving against username, because it is the unique case-insensitive key and a non-unique nickname would make @alice ambiguous the moment two people pick the same one. POST /api/v1/users/me/avatar takes 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 and users.avatar is 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/me still takes an https URL, and both paths end at the same column. Real invisible is the load-bearing change. users.status stores the status the user chose, invisible included; the collapse to offline happens 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 says offline, 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 serve no longer stamps online on connect, it reads the saved status (db.ConnectStatus: idle/dnd/invisible survive, anything else becomes online) and announces that, before buildReady runs so the member list and the broadcast cannot disagree. A chosen status now survives a disconnect (MarkUserDisconnected clears only online) 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's restoreSavedPresence shrank 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 @here fan-out, which tested status == "offline" literally and so would have pinged exactly the people who had asked not to be seen; it now collapses through db.BroadcastStatus first, so @here skips invisible readers and @everyone still reaches them. Custom status rides the presence payload rather than getting its own message: presence_update takes an optional custom_status where 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 needed userStatus to record who chose the status ("auto" vs "manual"), which is also what lets a stored pre-phase-6 "offline" be migrated to invisible on read. Client: a shared @lib/avatar helper 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 @handle underneath so the thing you would actually type is still one glance away. The about section 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 sends invisible as its own value.
  • DONE (2026-08-01) — Group DMs. dm_participants always 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 (28 others, 310 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), and DELETE /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. Migration 028 adds channels.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.DMChannelInfo grew recipients, name and is_group, and recipient stayed as the first of recipients so a pre-group client still renders somebody; db.NewDMChannelInfo is the single place that answers "which of these is the recipient", so GET /dms, the ready payload and dm_channel_open cannot disagree about a channel. dm_channel_open is now built per viewerrecipient and recipients are defined relative to who is reading, so one shared payload would list a group member as their own DM partner. GetUserDMChannels became 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: requireDMNotBlocked exempts 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 through dm_participants and needed no change; the tests pin that it genuinely reaches the third member. Voice in a group DM works through the existing participant check (hasChannelAccessIsDMParticipant), 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, and dmDisplayName as 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) and call_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, which voice_state already 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_ring is 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_decline is 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-ring with no DOM in it — accept, decline, 30s timeout, call_declined, and the ringer's voice_leave all exit through one stopRinging, 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 onFriendsClick no call site ever passed and whose friendsActive no 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 reason desktopNotifications and notificationSounds live 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):

  • sounds table — the soundboard is absent wholesale, so the table has no feature to belong to; it and the client's getSounds/deleteSound, which still call unregistered routes, are the largest remaining piece.
  • voice_speakers reserved 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 in ready and cleared by channel_focus.
  • The emoji table and the client's getEmoji/deleteEmoji (phase 6) — the table is written by /api/v1/emoji, and the two dead methods were replaced by listEmoji/uploadEmoji/deleteEmoji against the real routes.
  • UserProfilePopup's about section (phase 6) — built, styled and tested while every call site passed a hardcoded null; users.about now feeds it.
  • The DM sidebar's Friends nav item (phase 6) — deleted rather than implemented. Its onFriendsClick was never passed by any call site and its friendsActive was 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.