Skip to content

feat(chat): invite players directly into a private room - #263

Merged
zaxovaiko merged 8 commits into
devfrom
feat/chat-room-invites
Oct 9, 2026
Merged

zaxovaiko merged 8 commits into
devfrom
feat/chat-room-invites

Conversation

@okapitula

@okapitula okapitula commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

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 moderatorInvite and onlyInvitedCanJoin flags 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.

  • Routes: 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.
  • Accepting reuses the join-by-code membership path (room advisory lock, chat.room.member.joined). Joins to a private room publish a chat:member-joined signal on the room channel, except joins by staff. A retried accept returns the joined room.
  • Notification type chat.room_invite.received. Its body carries no room-controlled text; the room name travels in data.
  • Domain events and audit records for invite sent, accepted, declined and expired. A lapsed invite is kept with status expired, never deleted.
  • An invite stops being acceptable once the inviter leaves, loses the right to invite, closes their account or is platform-banned from the room.
  • A player under a responsible-gambling restriction or self-exclusion shows as unavailable and can't be invited; this is read through PLAY_ELIGIBILITY.
  • A refused invite, search or status lookup emits identity.user.unauthorized_access. Search and status lookup don't reveal blocks; an invite to or from a blocker is refused as unavailable.
  • Candidate search matches usernames only and needs at least three characters.
  • Sending invites is rate-limited to 30 per 15 minutes per user; search and status lookup to 120 per minute. The lookup limiter refuses requests when Redis is unavailable. ROOM_INVITE_SEARCH_MIN_LENGTH is 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

okapitula and others added 5 commits October 5, 2026 12:02
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 zaxovaiko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' }));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 zaxovaiko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few smaller ones, not blocking.

meta: { ip, userAgent },
predicate: and(
eq(chatRoom.id, candidate.id),
eq(chatRoom.joinCode, joinCode),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, a moderator can turn on moderatorInvite for themselves here, since only assertRoomModerator runs. Shouldn't that flag be owner-only?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
zaxovaiko previously approved these changes Oct 9, 2026

@zaxovaiko zaxovaiko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@zaxovaiko
zaxovaiko merged commit 96ebf20 into dev Oct 9, 2026
6 checks passed
@zaxovaiko
zaxovaiko deleted the feat/chat-room-invites branch October 9, 2026 18:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants