@openora/core 0.4.1-canary.71 → 0.4.1-canary.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/__tests__/backoffice.service.int.test.js +30 -12
  3. package/dist/admin-console/__tests__/backoffice.service.int.test.js.map +1 -1
  4. package/dist/admin-console/contract/index.d.ts +4 -0
  5. package/dist/admin-console/contract/index.d.ts.map +1 -1
  6. package/dist/admin-console/contract/index.js +3 -2
  7. package/dist/admin-console/contract/index.js.map +1 -1
  8. package/dist/admin-console/plugin.d.ts +1 -0
  9. package/dist/admin-console/plugin.d.ts.map +1 -1
  10. package/dist/admin-console/router/index.d.ts +2 -0
  11. package/dist/admin-console/router/index.d.ts.map +1 -1
  12. package/dist/admin-console/service/backoffice.service.d.ts +2 -0
  13. package/dist/admin-console/service/backoffice.service.d.ts.map +1 -1
  14. package/dist/admin-console/service/backoffice.service.js +2 -0
  15. package/dist/admin-console/service/backoffice.service.js.map +1 -1
  16. package/dist/analytics/plugin.d.ts +1 -0
  17. package/dist/analytics/plugin.d.ts.map +1 -1
  18. package/dist/audit/plugin.d.ts +1 -0
  19. package/dist/audit/plugin.d.ts.map +1 -1
  20. package/dist/audit/plugin.js +6 -0
  21. package/dist/audit/plugin.js.map +1 -1
  22. package/dist/casino/gaming/plugin.d.ts +1 -0
  23. package/dist/casino/gaming/plugin.d.ts.map +1 -1
  24. package/dist/casino/lobby/plugin.d.ts +1 -0
  25. package/dist/casino/lobby/plugin.d.ts.map +1 -1
  26. package/dist/cms/plugin.d.ts +1 -0
  27. package/dist/cms/plugin.d.ts.map +1 -1
  28. package/dist/compliance/plugin.d.ts +1 -0
  29. package/dist/compliance/plugin.d.ts.map +1 -1
  30. package/dist/contracts/adapters/admin-user-directory.d.ts +4 -2
  31. package/dist/contracts/adapters/admin-user-directory.d.ts.map +1 -1
  32. package/dist/contracts/adapters/admin-user-directory.js.map +1 -1
  33. package/dist/contracts/adapters/chat-moderation.d.ts +75 -0
  34. package/dist/contracts/adapters/chat-moderation.d.ts.map +1 -0
  35. package/dist/contracts/adapters/chat-moderation.js +3 -0
  36. package/dist/contracts/adapters/chat-moderation.js.map +1 -0
  37. package/dist/contracts/adapters/index.d.ts +2 -0
  38. package/dist/contracts/adapters/index.d.ts.map +1 -1
  39. package/dist/contracts/adapters/index.js +1 -0
  40. package/dist/contracts/adapters/index.js.map +1 -1
  41. package/dist/contracts/adapters/realtime.d.ts +9 -1
  42. package/dist/contracts/adapters/realtime.d.ts.map +1 -1
  43. package/dist/contracts/adapters/realtime.js.map +1 -1
  44. package/dist/contracts/schemas/events.d.ts +19 -4
  45. package/dist/contracts/schemas/events.d.ts.map +1 -1
  46. package/dist/contracts/schemas/events.js +12 -5
  47. package/dist/contracts/schemas/events.js.map +1 -1
  48. package/dist/engagement/chat/__tests__/chat.service.int.test.js +315 -19
  49. package/dist/engagement/chat/__tests__/chat.service.int.test.js.map +1 -1
  50. package/dist/engagement/chat/contract/constants.d.ts +1 -1
  51. package/dist/engagement/chat/contract/constants.d.ts.map +1 -1
  52. package/dist/engagement/chat/contract/constants.js +1 -1
  53. package/dist/engagement/chat/contract/constants.js.map +1 -1
  54. package/dist/engagement/chat/contract/index.d.ts +764 -18
  55. package/dist/engagement/chat/contract/index.d.ts.map +1 -1
  56. package/dist/engagement/chat/contract/index.js +236 -5
  57. package/dist/engagement/chat/contract/index.js.map +1 -1
  58. package/dist/engagement/chat/drizzle/migrations/0005_tranquil_gwen_stacy.sql +100 -0
  59. package/dist/engagement/chat/drizzle/migrations/0006_tiny_ultron.sql +3 -0
  60. package/dist/engagement/chat/drizzle/migrations/meta/0005_snapshot.json +1422 -0
  61. package/dist/engagement/chat/drizzle/migrations/meta/0006_snapshot.json +1456 -0
  62. package/dist/engagement/chat/drizzle/migrations/meta/_journal.json +14 -0
  63. package/dist/engagement/chat/plugin.d.ts +2 -1
  64. package/dist/engagement/chat/plugin.d.ts.map +1 -1
  65. package/dist/engagement/chat/plugin.js +25 -13
  66. package/dist/engagement/chat/plugin.js.map +1 -1
  67. package/dist/engagement/chat/router/index.d.ts +518 -18
  68. package/dist/engagement/chat/router/index.d.ts.map +1 -1
  69. package/dist/engagement/chat/router/index.js +140 -20
  70. package/dist/engagement/chat/router/index.js.map +1 -1
  71. package/dist/engagement/chat/schema/index.d.ts +1017 -16
  72. package/dist/engagement/chat/schema/index.d.ts.map +1 -1
  73. package/dist/engagement/chat/schema/index.js +110 -4
  74. package/dist/engagement/chat/schema/index.js.map +1 -1
  75. package/dist/engagement/chat/service/chat-ban.service.d.ts +26 -0
  76. package/dist/engagement/chat/service/chat-ban.service.d.ts.map +1 -0
  77. package/dist/engagement/chat/service/chat-ban.service.js +139 -0
  78. package/dist/engagement/chat/service/chat-ban.service.js.map +1 -0
  79. package/dist/engagement/chat/service/chat-message-moderation.service.d.ts +14 -0
  80. package/dist/engagement/chat/service/chat-message-moderation.service.d.ts.map +1 -0
  81. package/dist/engagement/chat/service/chat-message-moderation.service.js +48 -0
  82. package/dist/engagement/chat/service/chat-message-moderation.service.js.map +1 -0
  83. package/dist/engagement/chat/service/chat-moderation.service.d.ts +30 -0
  84. package/dist/engagement/chat/service/chat-moderation.service.d.ts.map +1 -0
  85. package/dist/engagement/chat/service/chat-moderation.service.js +40 -0
  86. package/dist/engagement/chat/service/chat-moderation.service.js.map +1 -0
  87. package/dist/engagement/chat/service/chat-mute.service.d.ts +26 -0
  88. package/dist/engagement/chat/service/chat-mute.service.d.ts.map +1 -0
  89. package/dist/engagement/chat/service/chat-mute.service.js +143 -0
  90. package/dist/engagement/chat/service/chat-mute.service.js.map +1 -0
  91. package/dist/engagement/chat/service/chat-room-ban.service.d.ts +29 -0
  92. package/dist/engagement/chat/service/chat-room-ban.service.d.ts.map +1 -0
  93. package/dist/engagement/chat/service/chat-room-ban.service.js +105 -0
  94. package/dist/engagement/chat/service/chat-room-ban.service.js.map +1 -0
  95. package/dist/engagement/chat/service/chat-room-membership.service.d.ts +72 -0
  96. package/dist/engagement/chat/service/chat-room-membership.service.d.ts.map +1 -0
  97. package/dist/engagement/chat/service/chat-room-membership.service.js +176 -0
  98. package/dist/engagement/chat/service/chat-room-membership.service.js.map +1 -0
  99. package/dist/engagement/chat/service/chat-room-mute.service.d.ts +25 -0
  100. package/dist/engagement/chat/service/chat-room-mute.service.d.ts.map +1 -0
  101. package/dist/engagement/chat/service/chat-room-mute.service.js +89 -0
  102. package/dist/engagement/chat/service/chat-room-mute.service.js.map +1 -0
  103. package/dist/engagement/chat/service/chat.service.d.ts +348 -61
  104. package/dist/engagement/chat/service/chat.service.d.ts.map +1 -1
  105. package/dist/engagement/chat/service/chat.service.js +460 -194
  106. package/dist/engagement/chat/service/chat.service.js.map +1 -1
  107. package/dist/engagement/chat/service/errors/chat-moderation.errors.d.ts +21 -0
  108. package/dist/engagement/chat/service/errors/chat-moderation.errors.d.ts.map +1 -0
  109. package/dist/engagement/chat/service/errors/chat-moderation.errors.js +31 -0
  110. package/dist/engagement/chat/service/errors/chat-moderation.errors.js.map +1 -0
  111. package/dist/engagement/chat-commands/contract/index.js +1 -1
  112. package/dist/engagement/chat-commands/contract/index.js.map +1 -1
  113. package/dist/engagement/chat-commands/drizzle/migrations/0001_sturdy_barracuda.sql +1 -0
  114. package/dist/engagement/chat-commands/drizzle/migrations/meta/0001_snapshot.json +83 -0
  115. package/dist/engagement/chat-commands/drizzle/migrations/meta/_journal.json +7 -0
  116. package/dist/engagement/chat-commands/plugin.d.ts +1 -0
  117. package/dist/engagement/chat-commands/plugin.d.ts.map +1 -1
  118. package/dist/engagement/chat-commands/schema/index.d.ts +0 -199
  119. package/dist/engagement/chat-commands/schema/index.d.ts.map +1 -1
  120. package/dist/engagement/chat-commands/schema/index.js +1 -19
  121. package/dist/engagement/chat-commands/schema/index.js.map +1 -1
  122. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.js +15 -1
  123. package/dist/engagement/chat-commands/service/__tests__/chat-commands.service.test.js.map +1 -1
  124. package/dist/engagement/chat-commands/service/chat-commands.service.d.ts +1 -0
  125. package/dist/engagement/chat-commands/service/chat-commands.service.d.ts.map +1 -1
  126. package/dist/engagement/chat-commands/service/chat-commands.service.js +38 -3
  127. package/dist/engagement/chat-commands/service/chat-commands.service.js.map +1 -1
  128. package/dist/engagement/notifications/plugin.d.ts +1 -0
  129. package/dist/engagement/notifications/plugin.d.ts.map +1 -1
  130. package/dist/engagement/social/plugin.d.ts +1 -0
  131. package/dist/engagement/social/plugin.d.ts.map +1 -1
  132. package/dist/engagement/social-transfers/plugin.d.ts +1 -0
  133. package/dist/engagement/social-transfers/plugin.d.ts.map +1 -1
  134. package/dist/engagement/social-transfers/schema/index.d.ts +1 -2
  135. package/dist/engagement/social-transfers/schema/index.d.ts.map +1 -1
  136. package/dist/engagement/social-transfers/schema/index.js +1 -2
  137. package/dist/engagement/social-transfers/schema/index.js.map +1 -1
  138. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.js +19 -0
  139. package/dist/engagement/social-transfers/service/__tests__/social-transfers.service.test.js.map +1 -1
  140. package/dist/iam/plugin.d.ts +1 -0
  141. package/dist/iam/plugin.d.ts.map +1 -1
  142. package/dist/iam/seed/data/default-admin-roles.d.ts.map +1 -1
  143. package/dist/iam/seed/data/default-admin-roles.js +3 -0
  144. package/dist/iam/seed/data/default-admin-roles.js.map +1 -1
  145. package/dist/pam/identity/__tests__/admin-user-directory.int.test.js +33 -3
  146. package/dist/pam/identity/__tests__/admin-user-directory.int.test.js.map +1 -1
  147. package/dist/pam/identity/admin-user-directory.d.ts +12 -0
  148. package/dist/pam/identity/admin-user-directory.d.ts.map +1 -1
  149. package/dist/pam/identity/admin-user-directory.js +25 -25
  150. package/dist/pam/identity/admin-user-directory.js.map +1 -1
  151. package/dist/pam/identity/plugin.d.ts +1 -0
  152. package/dist/pam/identity/plugin.d.ts.map +1 -1
  153. package/dist/pam/player-management/__tests__/player.service.int.test.js +4 -0
  154. package/dist/pam/player-management/__tests__/player.service.int.test.js.map +1 -1
  155. package/dist/pam/player-management/plugin.d.ts +1 -0
  156. package/dist/pam/player-management/plugin.d.ts.map +1 -1
  157. package/dist/pam/player-note/plugin.d.ts +1 -0
  158. package/dist/pam/player-note/plugin.d.ts.map +1 -1
  159. package/dist/pam/profile/plugin.d.ts +1 -0
  160. package/dist/pam/profile/plugin.d.ts.map +1 -1
  161. package/dist/pam/tag/plugin.d.ts +1 -0
  162. package/dist/pam/tag/plugin.d.ts.map +1 -1
  163. package/dist/server/auth/permissions.d.ts +12 -12
  164. package/dist/server/auth/permissions.js +2 -2
  165. package/dist/server/auth/permissions.js.map +1 -1
  166. package/dist/server/kernel/__tests__/realtime-transport.test.js +7 -0
  167. package/dist/server/kernel/__tests__/realtime-transport.test.js.map +1 -1
  168. package/dist/server/kernel/orpc-error-map.d.ts.map +1 -1
  169. package/dist/server/kernel/orpc-error-map.js +4 -1
  170. package/dist/server/kernel/orpc-error-map.js.map +1 -1
  171. package/dist/server/kernel/realtime-transport.d.ts +3 -1
  172. package/dist/server/kernel/realtime-transport.d.ts.map +1 -1
  173. package/dist/server/kernel/realtime-transport.js +22 -4
  174. package/dist/server/kernel/realtime-transport.js.map +1 -1
  175. package/dist/server/runtime/core-token-catalog.d.ts +1 -0
  176. package/dist/server/runtime/core-token-catalog.d.ts.map +1 -1
  177. package/dist/server/runtime/core-token-catalog.js +2 -1
  178. package/dist/server/runtime/core-token-catalog.js.map +1 -1
  179. package/dist/testing/realtime-transport-conformance.d.ts.map +1 -1
  180. package/dist/testing/realtime-transport-conformance.js +4 -0
  181. package/dist/testing/realtime-transport-conformance.js.map +1 -1
  182. package/dist/wallet/__tests__/wallet.service.int.test.js +19 -2
  183. package/dist/wallet/__tests__/wallet.service.int.test.js.map +1 -1
  184. package/dist/wallet/contract/index.d.ts +2 -0
  185. package/dist/wallet/contract/index.d.ts.map +1 -1
  186. package/dist/wallet/contract/index.js +1 -0
  187. package/dist/wallet/contract/index.js.map +1 -1
  188. package/dist/wallet/plugin.d.ts +1 -0
  189. package/dist/wallet/plugin.d.ts.map +1 -1
  190. package/dist/wallet/router/index.d.ts +1 -0
  191. package/dist/wallet/router/index.d.ts.map +1 -1
  192. package/dist/wallet/service/wallet.service.d.ts +1 -0
  193. package/dist/wallet/service/wallet.service.d.ts.map +1 -1
  194. package/dist/wallet/service/wallet.service.js +1 -0
  195. package/dist/wallet/service/wallet.service.js.map +1 -1
  196. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
- import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { eq, sql } from 'drizzle-orm';
3
+ import { and, eq, isNull, sql } from 'drizzle-orm';
4
4
  import { createTestDb, InProcessRealtimeTransport } from '@openora/core/testing';
5
5
  import { user } from '@openora/core/pam/schema/identity';
6
6
  import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity';
@@ -18,16 +18,42 @@ import { migrate } from '../migrate.js';
18
18
  // NO_CLIENT_META,
19
19
  // } from '../../../testing/mock.js';
20
20
  import { chatChannel } from '@openora/core/contracts';
21
- import { ChatService, ChatRoomNotFoundError, ChatMessageNotFoundError, ChatMessageOwnershipError, ChatMessageBlockedError, ChatSelfBlockError, ChatSelfIgnoreError, ChatRoomSlugConflictError, ChatRoomJoinCodeNotFoundError, ChatRoomBannedError, ChatRoomLimitReachedError, ChatRoomLastModeratorError, ChatRoomNotMemberError, ChatRoomNotModeratorError, ChatRoomSelfModerationError, } from '../service/chat.service.js';
22
- import { chatMessage, chatRoom, chatRoomMember, chatRoomBan, chatUserBlock, chatUserIgnore, } from '../schema/index.js';
21
+ import { ChatService, ChatMessageBlockedError, ChatSelfBlockError, ChatSelfIgnoreError, ChatRoomSlugConflictError, ChatRoomJoinCodeNotFoundError, ChatRoomBannedError, ChatRoomLimitReachedError, ChatRoomLastModeratorError, ChatRoomNotMemberError, ChatRoomNotModeratorError, ChatRoomSelfModerationError, ChatRoomOwnershipError, } from '../service/chat.service.js';
22
+ import { ChatModerationService, ChatRoomNotFoundError, ChatMessageNotFoundError, ChatPlayerMutedError, ChatPlayerBannedError, ChatAdminPrivateRoomModerationError, } from '../service/chat-moderation.service.js';
23
+ import { chatMessage, chatRoom, chatRoomMember, chatRoomBan, chatRoomRule, chatRoomConfiguration, chatRoomMute, chatRoomRemove, chatUserBlock, chatUserIgnore, chatMute, chatPlatformBan, } from '../schema/index.js';
24
+ import { ChatRoomMembershipService } from '../service/chat-room-membership.service.js';
25
+ import { ChatRoomBanService } from '../service/chat-room-ban.service.js';
26
+ import { ChatRoomMuteService } from '../service/chat-room-mute.service.js';
23
27
  let db;
24
28
  function makeService(directory = mock({ lookupPlayers: async () => [] })) {
25
29
  const transport = new InProcessRealtimeTransport();
26
30
  const events = makeEventBus();
31
+ const audit = mock({
32
+ record: vi.fn().mockResolvedValue(undefined),
33
+ recordInTransaction: vi.fn().mockResolvedValue(undefined),
34
+ });
35
+ const moderation = new ChatModerationService(db.drizzle, transport, audit);
36
+ const identityReader = makeIdentityReader();
37
+ const chatService = new ChatService(db.drizzle, events, transport, directory, audit, moderation, identityReader);
38
+ const membership = new ChatRoomMembershipService(db.drizzle, events, audit, transport, identityReader);
39
+ const roomBan = new ChatRoomBanService(db.drizzle, events, audit, transport, identityReader);
40
+ const roomMute = new ChatRoomMuteService(db.drizzle, audit);
27
41
  return {
28
- svc: new ChatService(db.drizzle, events, transport, directory, makeIdentityReader()),
42
+ moderation,
43
+ svc: Object.assign(chatService, {
44
+ joinRoom: membership.joinRoom.bind(membership),
45
+ joinPublicRoom: membership.joinPublicRoom.bind(membership),
46
+ adminJoinRoom: membership.adminJoinRoom.bind(membership),
47
+ leaveRoom: membership.leaveRoom.bind(membership),
48
+ removeMember: membership.removeMember.bind(membership),
49
+ banMember: roomBan.banMember.bind(roomBan),
50
+ unbanMember: roomBan.unbanMember.bind(roomBan),
51
+ muteRoomMember: roomMute.muteRoomMember.bind(roomMute),
52
+ unmuteRoomMember: roomMute.unmuteRoomMember.bind(roomMute),
53
+ }),
29
54
  events,
30
55
  transport,
56
+ audit,
31
57
  };
32
58
  }
33
59
  async function seedUser(name = 'Player') {
@@ -78,7 +104,7 @@ afterAll(async () => {
78
104
  await db.drop();
79
105
  });
80
106
  beforeEach(async () => {
81
- await db.drizzle.db.execute(sql `TRUNCATE ${chatMessage}, ${chatRoomMember}, ${chatRoomBan}, ${chatUserBlock}, ${chatUserIgnore}, ${chatRoom}, ${user} RESTART IDENTITY CASCADE`);
107
+ await db.drizzle.db.execute(sql `TRUNCATE ${chatMessage}, ${chatRoomRule}, ${chatRoomConfiguration}, ${chatRoomMember}, ${chatRoomBan}, ${chatRoomMute}, ${chatRoomRemove}, ${chatMute}, ${chatPlatformBan}, ${chatUserBlock}, ${chatUserIgnore}, ${chatRoom}, ${user} RESTART IDENTITY CASCADE`);
82
108
  });
83
109
  describe('Chat contract surface', () => {
84
110
  it('exposes the complete persisted category set', () => {
@@ -314,24 +340,37 @@ describe('ChatService.sendRoomMessage (real PG)', () => {
314
340
  });
315
341
  });
316
342
  describe('ChatService.deleteMessage (real PG)', () => {
317
- it('soft-deletes the senders own message', async () => {
343
+ it('soft-deletes a message for a room owner and publishes a tombstone', async () => {
318
344
  const { svc } = makeService();
345
+ const ownerId = randomUUID();
346
+ const globalRoom = await seedRoom({ slug: '__global', isPublic: true });
347
+ await db.drizzle.db
348
+ .insert(chatRoomMember)
349
+ .values({ roomId: globalRoom.id, userId: ownerId, role: 'owner' });
319
350
  const message = await seedMessage();
320
- await expect(svc.deleteMessage(message.id, message.userId)).resolves.toEqual({ success: true });
351
+ await expect(svc.deleteMessage(message.id, ownerId)).resolves.toEqual({ success: true });
321
352
  const [stored] = await db.drizzle.db
322
353
  .select()
323
354
  .from(chatMessage)
324
355
  .where(eq(chatMessage.id, message.id));
325
356
  expect(stored?.isDeleted).toBe(true);
357
+ expect(stored?.deletedAt).toBeInstanceOf(Date);
326
358
  });
327
359
  it('throws ChatMessageNotFoundError for an unknown id', async () => {
328
360
  const { svc } = makeService();
329
361
  await expect(svc.deleteMessage(randomUUID(), randomUUID())).rejects.toBeInstanceOf(ChatMessageNotFoundError);
330
362
  });
331
- it('refuses to delete another players message', async () => {
363
+ it('refuses deletion by a regular room member', async () => {
332
364
  const { svc } = makeService();
365
+ const ownerId = randomUUID();
366
+ const memberId = randomUUID();
367
+ const globalRoom = await seedRoom({ slug: '__global', isPublic: true });
368
+ await db.drizzle.db.insert(chatRoomMember).values([
369
+ { roomId: globalRoom.id, userId: ownerId, role: 'owner' },
370
+ { roomId: globalRoom.id, userId: memberId, role: 'member' },
371
+ ]);
333
372
  const message = await seedMessage();
334
- await expect(svc.deleteMessage(message.id, randomUUID())).rejects.toBeInstanceOf(ChatMessageOwnershipError);
373
+ await expect(svc.deleteMessage(message.id, memberId)).rejects.toBeInstanceOf(ChatRoomNotModeratorError);
335
374
  });
336
375
  });
337
376
  describe('ChatService block list (real PG)', () => {
@@ -634,6 +673,11 @@ describe('ChatService admin rooms (real PG)', () => {
634
673
  });
635
674
  expect(room).toMatchObject({ slug: 'jackpot-wheel', isPublic: true });
636
675
  expect(typeof room.createdAt).toBe('string');
676
+ const [configuration] = await db.drizzle.db
677
+ .select()
678
+ .from(chatRoomConfiguration)
679
+ .where(eq(chatRoomConfiguration.roomId, room.id));
680
+ expect(configuration).toMatchObject({ roomId: room.id });
637
681
  expect(events.emit).toHaveBeenCalledWith('chat.room.created', expect.objectContaining({ roomId: room.id, slug: 'jackpot-wheel' }));
638
682
  });
639
683
  it('throws ChatRoomSlugConflictError when the slug is taken', async () => {
@@ -679,6 +723,18 @@ describe('ChatService admin rooms (real PG)', () => {
679
723
  after: expect.objectContaining({ name: 'New', category: 'regions' }),
680
724
  }));
681
725
  });
726
+ it('allows only the private-room creator to update its name', async () => {
727
+ const { svc } = makeService();
728
+ const ownerId = randomUUID();
729
+ const room = await svc.createPrivateRoom({ userId: ownerId, name: 'Old', ...NO_CLIENT_META });
730
+ await expect(svc.updatePrivateRoom({ id: room.id, name: 'New', actorId: ownerId, ...NO_CLIENT_META })).resolves.toMatchObject({ id: room.id, name: 'New' });
731
+ await expect(svc.updatePrivateRoom({
732
+ id: room.id,
733
+ name: 'Hijacked',
734
+ actorId: randomUUID(),
735
+ ...NO_CLIENT_META,
736
+ })).rejects.toBeInstanceOf(ChatRoomOwnershipError);
737
+ });
682
738
  it('rejects an update that collides with another rooms slug', async () => {
683
739
  const { svc } = makeService();
684
740
  await seedRoom({ slug: 'taken' });
@@ -702,6 +758,26 @@ describe('ChatService admin rooms (real PG)', () => {
702
758
  await svc.deleteRoom(room.id);
703
759
  await expect(svc.deleteRoom(room.id)).rejects.toBeInstanceOf(ChatRoomNotFoundError);
704
760
  });
761
+ it('ends a private room only for its owner and emits auditable before/after state', async () => {
762
+ const { svc, events } = makeService();
763
+ const ownerId = randomUUID();
764
+ const otherUserId = randomUUID();
765
+ const room = await svc.createPrivateRoom({
766
+ userId: ownerId,
767
+ name: 'End me',
768
+ ...NO_CLIENT_META,
769
+ });
770
+ await expect(svc.deletePrivateRoom({ roomId: room.id, userId: otherUserId, ...NO_CLIENT_META })).rejects.toBeInstanceOf(ChatRoomOwnershipError);
771
+ await expect(svc.deletePrivateRoom({ roomId: room.id, userId: ownerId, ...NO_CLIENT_META })).resolves.toEqual({ success: true });
772
+ const [stored] = await db.drizzle.db.select().from(chatRoom).where(eq(chatRoom.id, room.id));
773
+ expect(stored?.deletedAt).toBeInstanceOf(Date);
774
+ expect(events.emit).toHaveBeenCalledWith('chat.private_room.deleted', expect.objectContaining({
775
+ roomId: room.id,
776
+ creatorId: ownerId,
777
+ before: { name: 'End me', slug: room.slug, category: 'private-channels' },
778
+ after: { deletedAt: expect.any(String) },
779
+ }));
780
+ });
705
781
  });
706
782
  describe('ChatService.createPrivateRoom (real PG)', () => {
707
783
  it('creates the room, joins the creator as moderator, and issues a join code', async () => {
@@ -716,7 +792,7 @@ describe('ChatService.createPrivateRoom (real PG)', () => {
716
792
  .select()
717
793
  .from(chatRoomMember)
718
794
  .where(eq(chatRoomMember.roomId, room.id));
719
- expect(member).toMatchObject({ userId, role: 'moderator' });
795
+ expect(member).toMatchObject({ userId, role: 'owner' });
720
796
  expect(events.emit).toHaveBeenCalledWith('chat.private_room.created', expect.objectContaining({ roomId: room.id, creatorId: userId }));
721
797
  });
722
798
  it('rejects one room past the per-player cap', async () => {
@@ -751,6 +827,37 @@ describe('ChatService.createPrivateRoom (real PG)', () => {
751
827
  });
752
828
  });
753
829
  describe('ChatService.joinRoom (real PG)', () => {
830
+ it('joins a public room by its room id', async () => {
831
+ const { svc } = makeService();
832
+ const room = await seedRoom({ isPublic: true, joinCode: 'PUB123' });
833
+ const userId = randomUUID();
834
+ await expect(svc.joinPublicRoom({ roomId: room.id, userId, ...NO_CLIENT_META })).resolves.toMatchObject({ id: room.id });
835
+ const members = await db.drizzle.db
836
+ .select()
837
+ .from(chatRoomMember)
838
+ .where(eq(chatRoomMember.roomId, room.id));
839
+ expect(members.map((member) => member.userId)).toContain(userId);
840
+ });
841
+ it('creates a default-disabled configuration for new private rooms', async () => {
842
+ const { svc } = makeService();
843
+ const room = await svc.createPrivateRoom({
844
+ userId: randomUUID(),
845
+ name: 'Configured room',
846
+ ...NO_CLIENT_META,
847
+ });
848
+ const [configuration] = await db.drizzle.db
849
+ .select()
850
+ .from(chatRoomConfiguration)
851
+ .where(eq(chatRoomConfiguration.roomId, room.id));
852
+ expect(configuration).toMatchObject({
853
+ slowMode: false,
854
+ slowModeSeconds: 0,
855
+ readOnlyMode: false,
856
+ onlyInvitedCanJoin: false,
857
+ lockRoom: false,
858
+ moderatorInvite: false,
859
+ });
860
+ });
754
861
  it('joins by code and records the membership once', async () => {
755
862
  const { svc, events } = makeService();
756
863
  const creatorId = randomUUID();
@@ -819,7 +926,7 @@ describe('ChatService.leaveRoom (real PG)', () => {
819
926
  });
820
927
  describe('ChatService moderation (real PG)', () => {
821
928
  async function roomWithMember() {
822
- const { svc, events } = makeService();
929
+ const { svc, events, audit, moderation, transport } = makeService();
823
930
  const moderatorId = randomUUID();
824
931
  const room = await svc.createPrivateRoom({
825
932
  userId: moderatorId,
@@ -828,16 +935,20 @@ describe('ChatService moderation (real PG)', () => {
828
935
  });
829
936
  const memberId = randomUUID();
830
937
  await svc.joinRoom({ userId: memberId, joinCode: room.joinCode, ...NO_CLIENT_META });
831
- return { svc, events, room, moderatorId, memberId };
938
+ return { svc, events, room, moderatorId, memberId, audit, moderation, transport };
832
939
  }
833
940
  it('kicks a member, who can then rejoin with the code', async () => {
834
- const { svc, room, moderatorId, memberId } = await roomWithMember();
835
- await svc.kickMember({ moderatorId, roomId: room.id, userId: memberId, ...NO_CLIENT_META });
941
+ const { svc, room, moderatorId, memberId, transport } = await roomWithMember();
942
+ const received = [];
943
+ transport.subscribe(chatChannel(room.id), () => received.push(true), memberId);
944
+ await svc.removeMember({ moderatorId, roomId: room.id, userId: memberId, ...NO_CLIENT_META });
836
945
  const afterKick = await db.drizzle.db
837
946
  .select()
838
947
  .from(chatRoomMember)
839
948
  .where(eq(chatRoomMember.roomId, room.id));
840
949
  expect(afterKick.map((m) => m.userId)).not.toContain(memberId);
950
+ transport.publish(chatChannel(room.id), { type: 'chat.message.sent' });
951
+ expect(received).toEqual([]);
841
952
  await expect(svc.joinRoom({ userId: memberId, joinCode: room.joinCode, ...NO_CLIENT_META })).resolves.toMatchObject({ id: room.id });
842
953
  });
843
954
  it('bans a member, removing membership and blocking a rejoin', async () => {
@@ -856,16 +967,50 @@ describe('ChatService moderation (real PG)', () => {
856
967
  const { svc, room, memberId } = await roomWithMember();
857
968
  const otherId = randomUUID();
858
969
  await svc.joinRoom({ userId: otherId, joinCode: room.joinCode, ...NO_CLIENT_META });
859
- await expect(svc.kickMember({
970
+ await expect(svc.removeMember({
860
971
  moderatorId: memberId,
861
972
  roomId: room.id,
862
973
  userId: otherId,
863
974
  ...NO_CLIENT_META,
864
975
  })).rejects.toBeInstanceOf(ChatRoomNotModeratorError);
865
976
  });
977
+ it('allows an owner to moderate like a moderator', async () => {
978
+ const { svc, room, memberId } = await roomWithMember();
979
+ const ownerId = randomUUID();
980
+ await db.drizzle.db
981
+ .insert(chatRoomMember)
982
+ .values({ roomId: room.id, userId: ownerId, role: 'owner' });
983
+ await expect(svc.removeMember({
984
+ moderatorId: ownerId,
985
+ roomId: room.id,
986
+ userId: memberId,
987
+ ...NO_CLIENT_META,
988
+ })).resolves.toEqual({ success: true });
989
+ });
990
+ it('prevents a moderator from removing another moderator or the owner', async () => {
991
+ const { svc, room, moderatorId, memberId } = await roomWithMember();
992
+ const otherModeratorId = randomUUID();
993
+ await db.drizzle.db.insert(chatRoomMember).values({
994
+ roomId: room.id,
995
+ userId: otherModeratorId,
996
+ role: 'moderator',
997
+ });
998
+ await expect(svc.removeMember({
999
+ moderatorId: otherModeratorId,
1000
+ roomId: room.id,
1001
+ userId: moderatorId,
1002
+ ...NO_CLIENT_META,
1003
+ })).rejects.toBeInstanceOf(ChatRoomNotModeratorError);
1004
+ await expect(svc.removeMember({
1005
+ moderatorId: otherModeratorId,
1006
+ roomId: room.id,
1007
+ userId: memberId,
1008
+ ...NO_CLIENT_META,
1009
+ })).resolves.toEqual({ success: true });
1010
+ });
866
1011
  it('refuses moderation by a non-member', async () => {
867
1012
  const { svc, room, memberId } = await roomWithMember();
868
- await expect(svc.kickMember({
1013
+ await expect(svc.removeMember({
869
1014
  moderatorId: randomUUID(),
870
1015
  roomId: room.id,
871
1016
  userId: memberId,
@@ -874,7 +1019,7 @@ describe('ChatService moderation (real PG)', () => {
874
1019
  });
875
1020
  it('refuses self-moderation', async () => {
876
1021
  const { svc, room, moderatorId } = await roomWithMember();
877
- await expect(svc.kickMember({
1022
+ await expect(svc.removeMember({
878
1023
  moderatorId,
879
1024
  roomId: room.id,
880
1025
  userId: moderatorId,
@@ -887,6 +1032,156 @@ describe('ChatService moderation (real PG)', () => {
887
1032
  ...NO_CLIENT_META,
888
1033
  })).rejects.toBeInstanceOf(ChatRoomSelfModerationError);
889
1034
  });
1035
+ it('allows muted users to read but rejects sends for the configured channel', async () => {
1036
+ const { svc, audit, moderation } = makeService();
1037
+ const room = await seedRoom();
1038
+ const userId = randomUUID();
1039
+ await moderation.mute({
1040
+ userId,
1041
+ roomId: room.id,
1042
+ durationSeconds: 60,
1043
+ reason: 'spam',
1044
+ actorId: randomUUID(),
1045
+ ...NO_CLIENT_META,
1046
+ });
1047
+ await expect(svc.getRoomMessages({ roomId: room.id, viewerId: userId })).resolves.toEqual([]);
1048
+ await expect(svc.sendRoomMessage({ userId, username: 'Muted', roomId: room.id, content: 'hello' })).rejects.toBeInstanceOf(ChatPlayerMutedError);
1049
+ expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({ action: 'chat.mute.created', resourceType: 'chat_mute' }));
1050
+ });
1051
+ it('enforces global read-only mode on global sends', async () => {
1052
+ const { svc } = makeService();
1053
+ const globalRoom = await seedRoom({ slug: '__global' });
1054
+ await db.drizzle.db.insert(chatRoomConfiguration).values({
1055
+ roomId: globalRoom.id,
1056
+ readOnlyMode: true,
1057
+ });
1058
+ await expect(svc.sendGlobalMessage(randomUUID(), 'ReadOnly', 'hello')).rejects.toBeInstanceOf(ChatPlayerMutedError);
1059
+ });
1060
+ it.each(['__all_public', '__all'])('enforces %s mutes on global sends', async (scope) => {
1061
+ const { svc, moderation } = makeService();
1062
+ await moderation.mute({
1063
+ userId: randomUUID(),
1064
+ roomId: scope,
1065
+ durationSeconds: 60,
1066
+ reason: 'spam',
1067
+ actorId: randomUUID(),
1068
+ ...NO_CLIENT_META,
1069
+ });
1070
+ const [mute] = await db.drizzle.db.select().from(chatMute).limit(1);
1071
+ await expect(svc.sendGlobalMessage(mute.userId, 'Muted', 'hello')).rejects.toBeInstanceOf(ChatPlayerMutedError);
1072
+ });
1073
+ it('enforces an all-chat mute in private rooms', async () => {
1074
+ const { svc, moderation } = makeService();
1075
+ const { room } = await roomWithMember();
1076
+ const userId = randomUUID();
1077
+ await svc.joinRoom({ userId, joinCode: room.joinCode, ...NO_CLIENT_META });
1078
+ await moderation.mute({
1079
+ userId,
1080
+ roomId: '__all',
1081
+ durationSeconds: 60,
1082
+ reason: 'spam',
1083
+ actorId: randomUUID(),
1084
+ ...NO_CLIENT_META,
1085
+ });
1086
+ await expect(svc.sendRoomMessage({ userId, username: 'Muted', roomId: room.id, content: 'hello' })).rejects.toBeInstanceOf(ChatPlayerMutedError);
1087
+ });
1088
+ it('replaces an expired private-room mute instead of reusing it', async () => {
1089
+ const { svc, room, moderatorId, memberId } = await roomWithMember();
1090
+ await db.drizzle.db.insert(chatRoomMute).values({
1091
+ roomId: room.id,
1092
+ userId: memberId,
1093
+ mutedBy: moderatorId,
1094
+ reason: 'old reason',
1095
+ expiresAt: new Date(Date.now() - 1000),
1096
+ });
1097
+ await svc.muteRoomMember({
1098
+ roomId: room.id,
1099
+ userId: memberId,
1100
+ moderatorId,
1101
+ durationSeconds: 60,
1102
+ reason: 'new reason',
1103
+ });
1104
+ const rows = await db.drizzle.db
1105
+ .select()
1106
+ .from(chatRoomMute)
1107
+ .where(eq(chatRoomMute.roomId, room.id));
1108
+ expect(rows).toHaveLength(2);
1109
+ expect(rows.filter((row) => row.liftedAt)).toHaveLength(1);
1110
+ expect(rows.filter((row) => !row.liftedAt)).toHaveLength(1);
1111
+ });
1112
+ it('enforces a reversible platform ban only on public chat', async () => {
1113
+ const { svc, room, memberId, moderation } = await roomWithMember();
1114
+ const userId = memberId;
1115
+ await moderation.ban({
1116
+ userId,
1117
+ roomId: '__all_public',
1118
+ durationSeconds: null,
1119
+ reason: 'abuse',
1120
+ actorId: randomUUID(),
1121
+ ...NO_CLIENT_META,
1122
+ });
1123
+ await expect(svc.sendGlobalMessage(userId, 'Banned', 'hello')).rejects.toBeInstanceOf(ChatPlayerBannedError);
1124
+ await expect(svc.sendRoomMessage({ userId, username: 'Banned', roomId: room.id, content: 'hello' })).resolves.toMatchObject({ userId });
1125
+ await moderation.unban({
1126
+ userId,
1127
+ roomId: '__all_public',
1128
+ actorId: randomUUID(),
1129
+ ...NO_CLIENT_META,
1130
+ });
1131
+ await expect(svc.sendGlobalMessage(userId, 'Banned', 'hello')).resolves.toMatchObject({
1132
+ userId,
1133
+ });
1134
+ });
1135
+ it('replaces expired admin bans and rejects private-room targets', async () => {
1136
+ const { svc, moderation } = makeService();
1137
+ const userId = randomUUID();
1138
+ const actorId = randomUUID();
1139
+ const room = await seedRoom();
1140
+ await db.drizzle.db.insert(chatPlatformBan).values({
1141
+ userId,
1142
+ bannedBy: actorId,
1143
+ scope: 'room',
1144
+ roomId: room.id,
1145
+ reason: 'old reason',
1146
+ expiresAt: new Date(Date.now() - 1000),
1147
+ });
1148
+ await moderation.ban({
1149
+ userId,
1150
+ roomId: room.id,
1151
+ durationSeconds: 60,
1152
+ reason: 'new reason',
1153
+ actorId,
1154
+ ...NO_CLIENT_META,
1155
+ });
1156
+ const active = await db.drizzle.db
1157
+ .select()
1158
+ .from(chatPlatformBan)
1159
+ .where(and(eq(chatPlatformBan.userId, userId), isNull(chatPlatformBan.liftedAt)));
1160
+ expect(active).toHaveLength(1);
1161
+ const privateRoom = await svc.createPrivateRoom({
1162
+ userId: actorId,
1163
+ name: 'Private',
1164
+ ...NO_CLIENT_META,
1165
+ });
1166
+ await expect(moderation.ban({
1167
+ userId,
1168
+ roomId: privateRoom.id,
1169
+ durationSeconds: null,
1170
+ reason: 'private target',
1171
+ actorId,
1172
+ ...NO_CLIENT_META,
1173
+ })).rejects.toBeInstanceOf(ChatAdminPrivateRoomModerationError);
1174
+ });
1175
+ it('admin deletion publishes a tombstone and records audit data', async () => {
1176
+ const { svc, audit, moderation } = makeService();
1177
+ const message = await seedMessage({ content: 'bad content' });
1178
+ const received = [];
1179
+ svc.subscribeMessages(null, (event) => received.push(event));
1180
+ await moderation.deleteMessage(message.id, randomUUID(), NO_CLIENT_META);
1181
+ expect(received[0]).toMatchObject({ id: message.id, isDeleted: true });
1182
+ expect(audit.record).toHaveBeenCalledWith(expect.objectContaining({ action: 'chat.message.deleted', resourceId: message.id }));
1183
+ await expect(svc.getGlobalMessages()).resolves.toEqual([]);
1184
+ });
890
1185
  });
891
1186
  describe('ChatService.verifyRoomAccess and listings (real PG)', () => {
892
1187
  it('returns a public room without checking membership', async () => {
@@ -930,6 +1225,7 @@ describe('ChatService.verifyRoomAccess and listings (real PG)', () => {
930
1225
  const now = new Date();
931
1226
  function summary(userId, username) {
932
1227
  return {
1228
+ playerId: randomUUID(),
933
1229
  userId,
934
1230
  username,
935
1231
  email: `${username}@example.test`,
@@ -958,7 +1254,7 @@ describe('ChatService.verifyRoomAccess and listings (real PG)', () => {
958
1254
  { userId: moderatorId, username: 'Moderator' },
959
1255
  { userId: memberId, username: 'SilentMember' },
960
1256
  ]);
961
- expect(members[0]).toMatchObject({ role: 'moderator' });
1257
+ expect(members[0]).toMatchObject({ role: 'owner' });
962
1258
  });
963
1259
  it('returns username: null when the directory does not resolve a member', async () => {
964
1260
  const directory = mock({ lookupPlayers: async () => [] });