Repository navigation
Resolve chat mentions by user id, not by username text #415
Description
Summary
A mention picked in the composer is stored in messages.html as a node with the user's id (data-id) and username (data-label). The mention triggers ignore that id. They match each @token in messages.content against users.username when the message is inserted. Users can change their username, and someone else can then claim the freed name. So a mention node with a stale label notifies the new holder of that name. The feed still links the same mention to the original user.
A stale label comes from a saved draft, or from a mention copied out of an older message. Nobody can force a user to give up a name, so this is a routing flaw, not account takeover.
- Severity: Low. The review lists it under "Info only".
- Area: Supabase notification triggers
- Source: security review of 2026-10-06, username reuse note
Production check (2026-10-06, read-only)
authenticated holds UPDATE on users.username.
Where
- Mention fan-out,
create_mention_notifications:packages/supabase/migrations/20260921120000_chat_mention_token_rule.sql:67-76(join public.users u on u.username = tokens.username). - Mention-message check,
create_regular_message_notifications: same file:171-181. - Script mirror:
packages/supabase/scripts/10-func-notifications.sql:71and:299. The triggerWHENclause readscontentonly (:86-90). - Mention node: the composer inserts
command({ id: item.id, label: item.username })atapps/webapp/src/components/chatroom/components/MessageComposer/helpers/MentionList.tsx:134.@tiptap/extension-mentionrenders it as aspanwithdata-type="mention",data-idanddata-label.sanitizeMessageContentkeepsdata-*attributes. - A click on a mention in the feed opens the profile by id:
apps/webapp/src/components/chatroom/hooks/useMentionClick.tsx:12-15. - Username is user-editable:
packages/supabase/scripts/13-RLS.sql:84-86(UPDATE grant includesusername). Format and uniqueness:packages/supabase/scripts/02-users.sql:13-16.
Fix plan
- In
create_mention_notifications, build the receivers from two sources, then keep today's filters (sender skipped, channel member, mute,notif_state):- Each mention node in
new.htmlgives itsdata-id. Skip an id that is not a uuid, such aseveryone. Read eachspantag on its own, so the match does not depend on attribute order. - Each text token in
new.contentstill matches by username, unless it equals thedata-labelof a mention node in the same message. - When
new.htmlis null, use the text rule alone, as today.
- Each mention node in
- Apply the same receiver rule to the mention-message check in
create_regular_message_notifications. The migration header says all fan-outs share one token rule, so the two must match. - Keep both functions
security definer;notificationshas no INSERT policy forauthenticated(packages/supabase/CLAUDE.md). Keep the triggerWHENclauses: every mention node also writes@labelintocontent. - Repo rules:
- Add a new migration with
create or replace functionfor both functions. - Mirror it in
packages/supabase/scripts/10-func-notifications.sql, then regenerateseed.sqlwithbun run --filter @docs.plus/supabase_back seed. - Run
bun run --filter @docs.plus/supabase_back types. No signature changes, so expect no diff.
- Add a new migration with
- Update
apps/webapp/src/components/chatroom/CLAUDE.md§Mention Picker. Its "Mention notifications (SQL)" bullet and itslabelnote say the SQL reads plain text only. This issue changes that rule.
Layer: the fix stays in the two SQL trigger functions. Do not change the composer, the mention node, or fetch_mentioned_users. Trust does not change: the sender already writes both content and html, and the member join still limits receivers.
Out of scope
- Typed text with no picked node. It keeps today's rule and names whoever holds the username when the message is sent. That is the user the picker shows for that name.
- A cooldown before a released username can be claimed again.
- A new
mentioned_user_idscolumn. The ids are already inmessages.html. - MCP
post_chat_message. It strips every@(apps/hocuspocus.server/src/modules/mcp/domain/toChatPost.ts:7), so it sends no mentions today.
Acceptance criteria
- A message whose mention node holds user A's id, with a label that is now user B's username, notifies A and not B.
- A typed
@usernamewith no mention node still notifies the current holder of that name. -
@everyonestill notifies every member who has not muted the channel. - A message that mentions a channel member still sends no
messagenotification to the other members. - A mention node that names a non-member notifies nobody, and the sender is never notified.
- A message with
htmlnull behaves as today.
Verify
- Apply the migration locally:
docker exec -i supabase_db_docsplus_supabase psql -U postgres -d postgres < packages/supabase/migrations/<file>.sql. - In
psql, inside a transaction that you roll back, insert one test message per acceptance case. Then read the new rows inpublic.notificationsfor thatmessage_id. - In the app, mention a user from the picker, send, and check that the bell shows the mention for that user.
Related
- Stop clients from writing server-owned columns on chat messages #400 (server-owned
messagescolumns;htmlstays client-written)
Generated by Claude Code
Activity
[-]Chat @mentions resolve by username text, so a reused username receives mentions meant for its old owner[/-][+]Resolve chat mentions by user id, not by username text[/+]fix(supabase): close chat mention, media, avatar and delete leaks
docs(hocuspocus): sync server docs with the 9 October fixes
docs(repo): record the 9 Oct security and email batch in the changelo…
HMarzban commentedon Oct 9, 2026
Fixed and live.
5c2da5fdebbdaae66c(production run 37976785981, green).20261009120300is applied, andinternal.mentioned_user_idsexists. A picked mention notifies the user its node names by id.