Repository navigation
feat(chat): invite players directly into a private room - #263
Conversation
Room owners, and moderators when the room allows it, can search players by username, check invite statuses, and send an invite that expires after seven days. Invitees list, accept or decline their invites; accepting joins the room. Adds the chat_room_invite table, an invite notification, audit records and a chat:member-joined realtime signal.
- Record a declined invite as a successful audit action. - Drop an invite once its inviter's account is closed or the inviter falls under a platform chat ban reaching the room, using the send-path scope rule. - Retire a lapsed invite with a terminal expired status instead of deleting it, and audit the lapse through chat.room.invite.expired. - Keep room-controlled text out of the invite notification body; the room name travels in data. - Require at least three characters for an invite candidate search.
A settings change could race an invite or accept under the separate configuration lock; it now serialises on the same per-room lock.
- Emit identity.user.unauthorized_access when an invite, candidate search or status lookup is refused for lack of the invite right. - Report players under a responsible-gambling restriction as unavailable, read through PLAY_ELIGIBILITY, and refuse inviting them. - Stop revealing blocks in search and lookup; the invite itself still refuses a blocker as unavailable. - Compute candidate status in one query instead of parallel queries on one connection. - Fail the lookup limiter closed, export the search minimum length, signal joins only for private rooms, and make a retried accept return the joined room.
zaxovaiko
left a comment
There was a problem hiding this comment.
Not quite yet - the pool lock-up on invite and the two RG gaps.
| .from(chatUserBlock) | ||
| .where(activeBlock(userId, actorId)) | ||
| .limit(1); | ||
| if (!target.visible || block || (await this.restrictedAmong([userId])).size > 0) { |
There was a problem hiding this comment.
Wait, restrictedAmong runs inside the transaction while we hold the chat-room: lock, and isRestricted takes a second connection from the pool. Ten concurrent invites into one room fill the pool with lock waiters, and the holder waits forever for a connection since there's no connectionTimeoutMillis. Let's run the eligibility check before transaction(...), like gaming/gaming.service.ts does.
There was a problem hiding this comment.
Moved the eligibility check out of the transaction, ahead of the room lock, like gaming does. Added a test sending concurrent invites into one room. cce1caa
| .filter((row) => !restricted.has(row.userId)) | ||
| .map((row) => [row.userId, row.inviteStatus]), | ||
| ); | ||
| return ids.map((id) => ({ userId: id, inviteStatus: statuses.get(id) ?? 'unavailable' })); |
There was a problem hiding this comment.
Any player can create a private room and send 100 ids here, and for an active player unavailable means self-excluded, RG-restricted, suspended or banned. So RG status becomes readable in bulk by anyone. Shouldn't restricted players just show as available, with the invite dropped silently on send?
There was a problem hiding this comment.
Search and lookup now show restricted, excluded, suspended, platform-banned and blocking players as available. An invite to one of them is stored as withheld: the inviter sees invited, the invitee is never notified and can't see or accept it, and it's audited. cce1caa
| } | ||
| return room; | ||
| } | ||
| const room = await this.membership.joinByInvite({ |
There was a problem hiding this comment.
Accept doesn't re-check isRestricted or player.status, so a player who self-excludes after being invited can still join for 7 days, and listMyRoomInvites keeps showing the invite. Let's check both before joinByInvite, outside the transaction.
There was a problem hiding this comment.
Accept and the invite list now refuse a restricted or excluded invitee. A restriction already revokes sessions, so this mainly covers player.status being set on its own. cce1caa
zaxovaiko
left a comment
There was a problem hiding this comment.
A few smaller ones, not blocking.
| meta: { ip, userAgent }, | ||
| predicate: and( | ||
| eq(chatRoom.id, candidate.id), | ||
| eq(chatRoom.joinCode, joinCode), |
There was a problem hiding this comment.
onlyInvitedCanJoin is still not read anywhere, so a leaked join code gets you in even with it on. Let's enforce it in join() or drop it from the description.
There was a problem hiding this comment.
Joining a private room by code now refuses an invite-only room unless you're already a member or an admin. Public rooms ignore the flag. cce1caa
| await this.assertRoomModerator(roomId, actorId); | ||
| const config = await this.drizzle.db.transaction((tx) => | ||
| withAdvisoryXactLock(tx, `chat-room-configuration:${roomId}`, async () => { | ||
| withAdvisoryXactLock(tx, `chat-room:${roomId}`, async () => { |
There was a problem hiding this comment.
Hm, a moderator can turn on moderatorInvite for themselves here, since only assertRoomModerator runs. Shouldn't that flag be owner-only?
There was a problem hiding this comment.
Only the owner can change moderatorInvite now, with the role read under the room lock, and a refused change is audited as unauthorized_access. A moderator resending the current value still gets through. cce1caa
| if (!target.visible || block || (await this.restrictedAmong([userId])).size > 0) { | ||
| throw new ChatRoomInviteeUnavailableError(userId); | ||
| } | ||
| // A lapsed invite still holds the one-pending slot in the partial unique index. |
There was a problem hiding this comment.
Lapsed invites stay pending unless the same player gets re-invited, so most expiries never reach the audit log, and a reopened inviter account brings them back. Maybe worth expiring them on leave/demote/close plus a sweep.
There was a problem hiding this comment.
Invites are now stored as expired when the inviter leaves, is kicked, banned or demoted, or closes their account, when the invitee is banned or closes their account, when the room is deleted, and when moderatorInvite is turned off. Each expiry is audited with its actor, and an hourly sweep catches the 7-day lapse and anything else. cce1caa
| .where(addressedToCaller(this.drizzle.db, new Date())) | ||
| .limit(1); | ||
| if (!invite) { | ||
| const room = await this.acceptedRoom(userId, inviteId); |
There was a problem hiding this comment.
Two accepts at the same moment give 200 and 404, since the fallback only runs when the pre-read misses. Let's also fall back to acceptedRoom when joinByInvite throws ChatRoomInviteNotFoundError.
There was a problem hiding this comment.
Accept now falls back to the already-joined room when a concurrent accept got there first, and accepted is emitted once. cce1caa
- Withhold invites to restricted, excluded or blocking players instead of refusing them, and keep the invite list empty for an excluded player. - Expire pending invites when an inviter leaves, is removed, demoted, banned or closes their account, when the invitee is banned or closes their account, and when the room is deleted; an hourly sweep expires the rest, and every expiry is audited with its actor. - Enforce invite-only joining on private rooms, and let only the owner switch moderator invites, auditing a refused change. - Serialize invites against platform bans and admin room deletes, and index pending invites by inviter.
zaxovaiko
left a comment
There was a problem hiding this comment.
Re-reviewed cce1caa: all seven findings addressed - eligibility hoisted out of the room transaction, invite statuses no longer leak RG state (withheld + chat.room.invite.withheld), accept and list re-check exclusion, joinByInvite falls back to the accepted room, onlyInvitedCanJoin enforced on join, moderatorInvite owner-only and expires pending moderator invites when turned off. Withdrawing my changes-requested.
One thing before this lands: the chat migration number 0017_chat_room_invite collides with #261's 0017_chat_player_cooldown. #261 lands first, so this one needs renumbering to 0018.
Summary
Private chat rooms gain direct invites: owners, and moderators where the room allows it, find players by username and invite them; the invitee gets a notification and can accept (joining the room) or decline within seven days. Base branch
dev; it does not stack on another PR.Why
Private rooms could only be joined by sharing a join code, and the room configuration's
moderatorInviteandonlyInvitedCanJoinflags had nothing reading them. An invite has to be checked against room membership, room and platform bans, player blocks and account status inside the join transaction, and has to avoid revealing which of those applies. Those all live in the chat module, so a consumer cannot add invites without duplicating them. Every operator offering private rooms needs a targeted invite that shows up in the player's notifications.POST /chat/rooms/{roomId}/invites,GET /chat/rooms/{roomId}/invite-candidates,POST /chat/rooms/{roomId}/invite-statuses,GET /chat/invites,POST /chat/invites/{inviteId}/accept,POST /chat/invites/{inviteId}/decline.chat.room.member.joined). Joins to a private room publish achat:member-joinedsignal on the room channel, except joins by staff. A retried accept returns the joined room.chat.room_invite.received. Its body carries no room-controlled text; the room name travels indata.expired, never deleted.PLAY_ELIGIBILITY.identity.user.unauthorized_access. Search and status lookup don't reveal blocks; an invite to or from a blocker is refused as unavailable.ROOM_INVITE_SEARCH_MIN_LENGTHis exported.Alternatives considered
Reusing join codes per player would leak a room-wide secret and could not be withdrawn when the inviter loses the right to invite. A stored invite whose liveness follows the inviter's current role can.
Risks
0017_chat_room_invite. The feat(chat): backoffice room page api with rules, configuration, stats and user-id lookups聽#260 stack also adds chat0017-0019. If it merges first, this branch mergesorigin/devand regenerates its migration under the next free number; the SQL does not change.chat:member-joined, except joins by staff.