Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions docs/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,26 @@
"tables": [
"chat_message",
"chat_room",
"chat_room_ban",
"chat_room_member",
"chat_user_block"
],
"routes": [
"chat.banMember",
"chat.blockUser",
"chat.createPrivateRoom",
"chat.createRoom",
"chat.deleteMessage",
"chat.deleteRoom",
"chat.getConnection",
"chat.getGlobalMessages",
"chat.getRoom",
"chat.getRoomMessages",
"chat.joinRoom",
"chat.kickMember",
"chat.leaveRoom",
"chat.listBlockedUsers",
"chat.listRoomMembers",
"chat.listRooms",
"chat.sendGlobalMessage",
"chat.sendRoomMessage",
Expand Down Expand Up @@ -517,6 +528,13 @@
],
"events": [
"chat.message.sent",
"chat.private_room.created",
"chat.room.created",
"chat.room.deleted",
"chat.room.member.banned",
"chat.room.member.joined",
"chat.room.member.kicked",
"chat.room.member.left",
"chat.user.blocked",
"chat.user.unblocked",
"cms.banner.created",
Expand Down Expand Up @@ -694,10 +712,22 @@
"name": "ChatMessageSchema",
"file": "packages/core/src/engagement/chat/contract/index.ts"
},
{
"name": "ChatRoomMemberSchema",
"file": "packages/core/src/engagement/chat/contract/index.ts"
},
{
"name": "ChatRoomRoleSchema",
"file": "packages/core/src/engagement/chat/contract/index.ts"
},
{
"name": "ChatRoomSchema",
"file": "packages/core/src/engagement/chat/contract/index.ts"
},
{
"name": "ChatRoomSlugSchema",
"file": "packages/core/src/engagement/chat/contract/index.ts"
},
{
"name": "CountryCodeSchema",
"file": "packages/core/src/contracts/schemas/igaming-config.ts"
Expand Down Expand Up @@ -1229,6 +1259,7 @@
"slots"
],
"httpRoutes": [
"DELETE /backoffice/chat/rooms/{id}",
"DELETE /chat/blocks/{blockedId}",
"DELETE /chat/messages/{id}",
"DELETE /cms/banners/{id}",
Expand All @@ -1250,6 +1281,8 @@
"GET /chat/connection",
"GET /chat/global",
"GET /chat/rooms",
"GET /chat/rooms/{roomId}",
"GET /chat/rooms/{roomId}/members",
"GET /chat/rooms/{roomId}/messages",
"GET /chat/stream",
"GET /cms/banners",
Expand Down Expand Up @@ -1294,8 +1327,14 @@
"PATCH /iam/roles/{roleId}",
"PATCH /identity/profile",
"PATCH /profile",
"POST /backoffice/chat/rooms",
"POST /chat/blocks",
"POST /chat/global",
"POST /chat/rooms/join",
"POST /chat/rooms/private",
"POST /chat/rooms/{roomId}/ban",
"POST /chat/rooms/{roomId}/kick",
"POST /chat/rooms/{roomId}/leave",
"POST /chat/rooms/{roomId}/messages",
"POST /cms/banners",
"POST /cms/pages",
Expand Down
53 changes: 53 additions & 0 deletions packages/core/src/audit/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,52 @@ function mapEventToRecord(topic: string, p: Record<string, unknown>): RecordInpu
};
}

// actorId = the moderator who acted; resource = the affected player in that room.
if (topic === 'chat.room.member.kicked' || topic === 'chat.room.member.banned') {
const actorKey = topic === 'chat.room.member.kicked' ? 'kickedBy' : 'bannedBy';
return {
...base,
actorType: 'player',
actorId: str(p[actorKey]),
resourceType: 'chat_room_member',
resourceId: str(p['userId']),
after: { roomId: str(p['roomId']) },
};
}

// actorId = the player who created the room; resource = the new room.
if (topic === 'chat.private_room.created') {
return {
...base,
actorType: 'player',
actorId: str(p['creatorId']),
resourceType: 'chat_room',
resourceId: str(p['roomId']),
};
}

if (topic === 'chat.room.created' || topic === 'chat.room.deleted') {
return {
...base,
actorType: 'admin',
actorId: str(p['actorId']),
resourceType: 'chat_room',
resourceId: str(p['roomId']),
...(topic === 'chat.room.created' ? { after: { name: str(p['name']) } } : {}),
};
}

if (topic === 'chat.room.member.joined' || topic === 'chat.room.member.left') {
return {
...base,
actorType: 'player',
actorId: str(p['userId']),
resourceType: 'chat_room_member',
resourceId: str(p['userId']),
after: { roomId: str(p['roomId']) },
};
}

// actorType = admin (the only path flipping isActive is the back-office route);
// resource = the subject user. after carries the new active state.
if (topic === 'identity.user.deactivated' || topic === 'identity.user.reactivated') {
Expand Down Expand Up @@ -357,6 +403,13 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [
// already persisted in chatMessage; the moderation/block actions are what we audit.
'chat.user.blocked',
'chat.user.unblocked',
'chat.private_room.created',
'chat.room.created',
'chat.room.deleted',
'chat.room.member.joined',
'chat.room.member.left',
'chat.room.member.kicked',
'chat.room.member.banned',
'compliance.limit.upserted',
'compliance.limit.removed',
'rg.limit.set',
Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/contracts/schemas/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,29 @@ export const domainEventSchemas = {
'chat.user.blocked': z.object({ blockerId: UuidSchema, blockedId: UuidSchema }),
'chat.user.unblocked': z.object({ blockerId: UuidSchema, blockedId: UuidSchema }),

// Admin room CRUD (actorId = acting admin UUID).
'chat.room.created': z.object({
roomId: UuidSchema,
name: z.string(),
actorId: UuidSchema.optional(),
}),
'chat.room.deleted': z.object({ roomId: UuidSchema, actorId: UuidSchema.optional() }),

// Private room lifecycle: creation and member membership changes.
'chat.private_room.created': z.object({ roomId: UuidSchema, creatorId: UuidSchema }),
'chat.room.member.joined': z.object({ roomId: UuidSchema, userId: UuidSchema }),
'chat.room.member.left': z.object({ roomId: UuidSchema, userId: UuidSchema }),
'chat.room.member.kicked': z.object({
roomId: UuidSchema,
userId: UuidSchema,
kickedBy: UuidSchema,
}),
'chat.room.member.banned': z.object({
roomId: UuidSchema,
userId: UuidSchema,
bannedBy: UuidSchema,
}),

// An admin added or changed a geo (country) rule (regulatory). `actorId` is the
// acting admin so the audit log can attribute the mutation.
'compliance.geo-rule.added': z.object({
Expand Down
20 changes: 17 additions & 3 deletions packages/core/src/engagement/chat/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
# Chat

Room-based and global messaging. Tables: `chatRoom` (name, slug unique, visibility flag), `chatMessage` (soft-delete via `isDeleted`, indexed by room + createdAt), `chatUserBlock` (directional mute, blocker-keyed).
Room-based and global messaging. Tables: `chatRoom` (name, slug unique, isPublic flag, joinCode unique nullable for private rooms, creatorId nullable), `chatMessage` (soft-delete via `isDeleted`, indexed by room + createdAt), `chatUserBlock` (directional mute, blocker-keyed), `chatRoomMember` (role: member/moderator, unique per room+user, cascade on room delete), `chatRoomBan` (unique per room+user, cascade on room delete).

Routes: `listRooms`, `getRoomMessages`/`sendRoomMessage` (both public for rooms; authenticated senders), `getGlobalMessages`/`sendGlobalMessage` (player-only, global scope), `deleteMessage` (player, ownership-enforced), `getConnection` (issues single-use realtime grant), `streamMessages` (SSE).
Routes (player-facing): `listRooms` (public rooms + private rooms the caller is a member of), `getRoomMessages`/`sendRoomMessage` (public read, authenticated send; private rooms require membership), `getGlobalMessages`/`sendGlobalMessage` (player-only, global scope), `deleteMessage` (player, ownership-enforced), `getConnection` (issues per-player realtime grant covering global + all public rooms + all private rooms where caller is a member), `streamMessages` (SSE, roomId null = global; private room check enforced at router level).

Per-viewer block filtering: message list filters out senders the viewer has blocked; blocked player is unaffected (can still send). Filters apply to both room and global streams. Username resolved from header (falls back to 'anonymous'); userId from auth. Realtime push over `REALTIME_TRANSPORT` + per-room grant (server authoritative, never client-supplied).
Routes (private room lifecycle): `createPrivateRoom` (player creates room, auto-joined as moderator, join code generated), `joinRoom` (join by code; 404 if invalid, 403 if banned), `leaveRoom` (idempotent), `getRoom` (room detail; joinCode populated for members of private rooms), `kickMember` (moderator removes member - can rejoin), `banMember` (moderator bans member - cannot rejoin even with code; idempotent), `listRoomMembers` (members only for private rooms).

Routes (admin-only, AdminGuard-enforced): `createRoom` (POST /backoffice/chat/rooms - creates public room by slug), `deleteRoom` (DELETE /backoffice/chat/rooms/{id} - cascades messages in tx, members/bans cascade via FK).

Per-viewer block filtering: message list filters out senders the viewer has blocked; blocked player is unaffected (can still send). Filters apply to both room and global streams. Username resolved from the verified user row (falls back to header, then 'anonymous'); userId from auth. Realtime push over `REALTIME_TRANSPORT`.

Access model: public rooms are open to all; private rooms require membership. `verifyRoomAccess(roomId, viewerId?)` is the single authority - called by `getRoomMessages`, `sendRoomMessage`, `getRoom`, `listRoomMembers`, and (via router) `streamMessages`. Moderator check in `kickMember`/`banMember`: caller must have `role = 'moderator'` in `chatRoomMember`.

Audit events: `chat.private_room.created`, `chat.room.member.kicked`, `chat.room.member.banned` are subscribed by the audit module. `chat.user.blocked`/`chat.user.unblocked` also audited. `chat.message.sent` is NOT audited (high-volume).

Join code: 6 chars, 31-char alphabet (no ambiguous 0/1/I/O/L), crypto `randomInt`. Slug for private rooms auto-generated as `private-{joinCode.toLowerCase()}`. Join code is globally unique (DB unique index).

Channel name convention (mirrors `chatChannel()` in the service): `chat:global` for null roomId, `chat:room:{roomId}` for rooms.

Admin role requires `chat-room` resource declared in `server/auth/permissions.ts`.
Loading
Loading